瀏覽代碼

fix(app): refresh V1 providers after auth (#38786)

Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
opencode-agent[bot] 3 周之前
父節點
當前提交
9e8b2171a5

+ 3 - 3
packages/app/src/components/dialog-connect-provider.tsx

@@ -159,7 +159,7 @@ function ProviderPicker(props: {
   const settings = useSettings()
   if (settings.general.newLayoutDesigns())
     return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
-  const providers = useProviders(props.directory)
+  const providers = useProviders(() => props.directory?.())
   const language = useLanguage()
   const popularGroup = () => language.t("dialog.provider.group.popular")
   const otherGroup = () => language.t("dialog.provider.group.other")
@@ -231,7 +231,7 @@ function ProviderPickerV2(props: {
   onSelect: (provider: string) => void
   onPrepare?: () => void
 }) {
-  const providers = useProviders(props.directory)
+  const providers = useProviders(() => props.directory?.())
   const language = useLanguage()
   const [store, setStore] = createStore({
     filter: "",
@@ -391,7 +391,7 @@ function ProviderConnection(props: {
   const language = useLanguage()
   const settings = useSettings()
   const newLayout = settings.general.newLayoutDesigns
-  const providers = useProviders(props.directory)
+  const providers = useProviders(() => props.directory?.())
   const directory = () => props.directory?.() ?? decode64(params.dir)
   const location = () => {
     const value = directory()

+ 1 - 1
packages/app/src/components/settings-providers.tsx

@@ -41,7 +41,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
   const serverSDK = useServerSDK()
   const protocol = useServerProtocol()
   const serverSync = useServerSync()
-  const providers = useProviders()
+  const providers = useProviders(() => undefined)
   const providerConnect = useProviderConnectController({ onBack: props.onBack })
 
   const connect = (provider?: string) => {

+ 18 - 2
packages/app/src/components/settings-v2/dialog-settings-v2.tsx

@@ -1,4 +1,4 @@
-import { Component, createSignal, startTransition } from "solid-js"
+import { Component, createMemo, createSignal, startTransition } from "solid-js"
 import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
 import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -11,6 +11,9 @@ import { SettingsModelsV2 } from "./models"
 import "./settings-v2.css"
 import { SettingsServersV2 } from "./servers"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useLayout } from "@/context/layout"
+import { useTabs } from "@/context/tabs"
+import { useServerSync } from "@/context/server-sync"
 
 export const DialogSettings: Component<{
   sessionID?: string
@@ -19,7 +22,20 @@ export const DialogSettings: Component<{
   const language = useLanguage()
   const platform = usePlatform()
   const dialog = useDialog()
+  const layout = useLayout()
+  const tabs = useTabs()
+  const serverSync = useServerSync()
   const [tab, setTab] = createSignal(props.defaultValue ?? "general")
+  const directory = createMemo(() => {
+    const route = layout.route()
+    if (route.type === "dir-new-sesssion") return route.dir
+    if (route.type === "draft") {
+      const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
+      return draft?.type === "draft" ? draft.directory : undefined
+    }
+    if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
+    return undefined
+  })
 
   const showProviders = () => {
     void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
@@ -87,7 +103,7 @@ export const DialogSettings: Component<{
           <SettingsServersV2 />
         </TabsV2.Content>
         <TabsV2.Content value="providers" class="settings-v2-panel">
-          <SettingsProvidersV2 onBack={showProviders} />
+          <SettingsProvidersV2 directory={directory} onBack={showProviders} />
         </TabsV2.Content>
         <TabsV2.Content value="models" class="settings-v2-panel">
           <SettingsModelsV2 />

+ 7 - 4
packages/app/src/components/settings-v2/providers.tsx

@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { showToast } from "@/utils/toast"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { createMemo, type Component, For, Show } from "solid-js"
+import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
@@ -29,18 +29,21 @@ const PROVIDER_NOTES = [
 
 const PROVIDER_ICON_SIZE = 16
 
-export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
+export const SettingsProvidersV2: Component<{
+  directory: Accessor<string | undefined>
+  onBack?: () => void
+}> = (props) => {
   const dialog = useDialog()
   const language = useLanguage()
   const serverSdk = useServerSDK()
   const protocol = useServerProtocol()
   const serverSync = useServerSync()
-  const providers = useProviders()
+  const providers = useProviders(props.directory)
   const providerConnect = useProviderConnectController({ onBack: props.onBack })
 
   const connect = (provider?: string) => {
     providerConnect.select(provider)
-    void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
+    void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
   }
 
   const connected = createMemo(() => {

+ 1 - 1
packages/app/src/context/models.tsx

@@ -26,7 +26,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
   name: "Models",
   gate: false,
   init: (props: { directory?: Accessor<string | undefined> } = {}) => {
-    const providers = useProviders(props.directory)
+    const providers = useProviders(() => props.directory?.())
 
     const [store, setStore, _, ready] = persisted(
       Persist.global("model", ["model.v1"]),

+ 6 - 4
packages/app/src/hooks/use-providers.ts

@@ -2,7 +2,7 @@ import { useServerSync } from "@/context/server-sync"
 import { decode64 } from "@/utils/base64"
 import { useParams } from "@solidjs/router"
 import { Iterable, pipe } from "effect"
-import type { Accessor } from "solid-js"
+import { createEffect, createMemo, type Accessor } from "solid-js"
 import { selectProviderCatalog } from "./provider-catalog"
 
 export const popularProviders = [
@@ -17,14 +17,14 @@ export const popularProviders = [
 ]
 const popularProviderSet = new Set(popularProviders)
 
-export function useProviders(directory?: Accessor<string | undefined>) {
+export function useProviders(directory: Accessor<string | undefined>) {
   const serverSync = useServerSync()
   const params = useParams()
   const dir = () => (directory ? directory() : decode64(params.dir))
   const providers = () => {
     const value = dir()
     const projectStore = value ? serverSync().child(value)[0] : undefined
-    if (directory)
+    if (value)
       return selectProviderCatalog({
         explicit: true,
         directory: value,
@@ -37,6 +37,7 @@ export function useProviders(directory?: Accessor<string | undefined>) {
       global: serverSync().data.provider,
     })
   }
+
   return {
     all: () => providers().all,
     default: () => providers().default,
@@ -58,7 +59,7 @@ export function useProviders(directory?: Accessor<string | undefined>) {
     },
     paid: () => {
       const connected = new Set(providers().connected)
-      return [
+      const paid = [
         ...Iterable.filter(
           providers().all,
           ([id]) =>
@@ -66,6 +67,7 @@ export function useProviders(directory?: Accessor<string | undefined>) {
             (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
         ),
       ]
+      return paid
     },
   }
 }

+ 1 - 1
packages/app/src/pages/layout.tsx

@@ -119,7 +119,7 @@ export default function LegacyLayout(props: ParentProps) {
   const permission = usePermission()
   const navigate = useNavigate()
   setNavigate(navigate)
-  const providers = useProviders()
+  const providers = useProviders(() => undefined)
   const dialog = useDialog()
   const command = useCommand()
   const theme = useTheme()

+ 27 - 23
packages/app/src/pages/session/composer/session-composer-controls.ts

@@ -25,35 +25,39 @@ export function createPromptInputController(input: {
 }) {
   const layout = useLayout()
   const local = useLocal()
-  const providers = useProviders()
-  const sync = useSync()
   const sdk = useSDK()
+  const sync = useSync()
+  const providers = useProviders(() => sdk().directory)
   const view = layout.view(input.sessionKey)
   const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
   const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
   const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
 
-  return createMemo<PromptInputControls>(() => ({
-    agents: {
-      available: sync().data.agent,
-      options: local.agent.list().map((agent) => agent.name),
-      current: local.agent.current()?.name ?? "",
-      loading: agentsQuery.isLoading,
-      visible: local.agent.visible(),
-      select: local.agent.set,
-    },
-    model: {
-      selection: input.model ?? local.model,
-      paid: providers.paid().length > 0,
-      loading:
-        (local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading,
-    },
-    session: {
-      id: input.sessionID(),
-      tabs: layout.tabs(input.sessionKey),
-      reviewPanel: view.reviewPanel,
-    },
-  }))
+  return createMemo<PromptInputControls>(() => {
+    return {
+      agents: {
+        available: sync().data.agent,
+        options: local.agent.list().map((agent) => agent.name),
+        current: local.agent.current()?.name ?? "",
+        loading: agentsQuery.isLoading,
+        visible: local.agent.visible(),
+        select: local.agent.set,
+      },
+      model: {
+        selection: input.model ?? local.model,
+        paid: providers.paid().length > 0,
+        loading:
+          (local.agent.visible() && agentsQuery.isLoading) ||
+          providersQuery.isLoading ||
+          globalProvidersQuery.isLoading,
+      },
+      session: {
+        id: input.sessionID(),
+        tabs: layout.tabs(input.sessionKey),
+        reviewPanel: view.reviewPanel,
+      },
+    }
+  })
 }
 
 export function createPromptProjectControls() {

+ 37 - 0
packages/app/src/utils/server-compat.test.ts

@@ -190,4 +190,41 @@ describe("createCompatibleApi", () => {
     expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1")
     expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other")
   })
+
+  test("disposes the V1 instance after connecting a provider", async () => {
+    const { api, requests } = setup("v1")
+
+    await api.integration.connect.key({
+      integrationID: "openrouter",
+      key: "secret",
+      location: { directory: "/repo" },
+    })
+
+    expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
+      "/auth/openrouter",
+      "/instance/dispose",
+      "/instance/dispose",
+    ])
+    expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
+    expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
+  })
+
+  test("disposes the V1 instance after completing provider OAuth", async () => {
+    const { api, requests } = setup("v1")
+
+    await api.integration.oauth.complete({
+      integrationID: "openrouter",
+      attemptID: "openrouter:0",
+      code: "code",
+      location: { directory: "/repo" },
+    })
+
+    expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
+      "/provider/openrouter/oauth/callback",
+      "/instance/dispose",
+      "/instance/dispose",
+    ])
+    expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
+    expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
+  })
 })

+ 6 - 0
packages/app/src/utils/server-compat.ts

@@ -401,6 +401,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
             providerID: value.integrationID,
             auth: { type: "api", key: value.key },
           })
+          await legacy(value.location).instance.dispose()
+          await input.legacy().instance.dispose()
         },
       },
       oauth: {
@@ -429,6 +431,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
             { providerID: value.integrationID, method, code: value.code },
             { throwOnError: true },
           )
+          await legacy(value.location).instance.dispose()
+          await input.legacy().instance.dispose()
         },
         status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
           const method = Number(value.attemptID.split(":").at(-1))
@@ -436,6 +440,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
             { providerID: value.integrationID, method },
             { throwOnError: true },
           )
+          await legacy(value.location).instance.dispose()
+          await input.legacy().instance.dispose()
           return located(
             { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
             value.location,