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>
This commit is contained in:
opencode-agent[bot]
2026-07-25 14:58:05 +08:00
committed by GitHub
parent 5e2a6257b2
commit 9e8b2171a5
10 changed files with 107 additions and 39 deletions
@@ -159,7 +159,7 @@ function ProviderPicker(props: {
const settings = useSettings() const settings = useSettings()
if (settings.general.newLayoutDesigns()) if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} /> 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 language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other") const otherGroup = () => language.t("dialog.provider.group.other")
@@ -231,7 +231,7 @@ function ProviderPickerV2(props: {
onSelect: (provider: string) => void onSelect: (provider: string) => void
onPrepare?: () => void onPrepare?: () => void
}) { }) {
const providers = useProviders(props.directory) const providers = useProviders(() => props.directory?.())
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
filter: "", filter: "",
@@ -391,7 +391,7 @@ function ProviderConnection(props: {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(props.directory) const providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir) const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => { const location = () => {
const value = directory() const value = directory()
@@ -41,7 +41,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const protocol = useServerProtocol() const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders() const providers = useProviders(() => undefined)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => { const connect = (provider?: string) => {
@@ -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 { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -11,6 +11,9 @@ import { SettingsModelsV2 } from "./models"
import "./settings-v2.css" import "./settings-v2.css"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
import { useDialog } from "@opencode-ai/ui/context/dialog" 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<{ export const DialogSettings: Component<{
sessionID?: string sessionID?: string
@@ -19,7 +22,20 @@ export const DialogSettings: Component<{
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const layout = useLayout()
const tabs = useTabs()
const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general") 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 = () => { const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />) void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
@@ -87,7 +103,7 @@ export const DialogSettings: Component<{
<SettingsServersV2 /> <SettingsServersV2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel"> <TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 onBack={showProviders} /> <SettingsProvidersV2 directory={directory} onBack={showProviders} />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel"> <TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 /> <SettingsModelsV2 />
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" 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 { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk" import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
@@ -29,18 +29,21 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16 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 dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const protocol = useServerProtocol() const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders() const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => { const connect = (provider?: string) => {
providerConnect.select(provider) providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />) void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
} }
const connected = createMemo(() => { const connected = createMemo(() => {
+1 -1
View File
@@ -26,7 +26,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
name: "Models", name: "Models",
gate: false, gate: false,
init: (props: { directory?: Accessor<string | undefined> } = {}) => { init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders(props.directory) const providers = useProviders(() => props.directory?.())
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]), Persist.global("model", ["model.v1"]),
+6 -4
View File
@@ -2,7 +2,7 @@ import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect" import { Iterable, pipe } from "effect"
import type { Accessor } from "solid-js" import { createEffect, createMemo, type Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog" import { selectProviderCatalog } from "./provider-catalog"
export const popularProviders = [ export const popularProviders = [
@@ -17,14 +17,14 @@ export const popularProviders = [
] ]
const popularProviderSet = new Set(popularProviders) const popularProviderSet = new Set(popularProviders)
export function useProviders(directory?: Accessor<string | undefined>) { export function useProviders(directory: Accessor<string | undefined>) {
const serverSync = useServerSync() const serverSync = useServerSync()
const params = useParams() const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir)) const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => { const providers = () => {
const value = dir() const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined const projectStore = value ? serverSync().child(value)[0] : undefined
if (directory) if (value)
return selectProviderCatalog({ return selectProviderCatalog({
explicit: true, explicit: true,
directory: value, directory: value,
@@ -37,6 +37,7 @@ export function useProviders(directory?: Accessor<string | undefined>) {
global: serverSync().data.provider, global: serverSync().data.provider,
}) })
} }
return { return {
all: () => providers().all, all: () => providers().all,
default: () => providers().default, default: () => providers().default,
@@ -58,7 +59,7 @@ export function useProviders(directory?: Accessor<string | undefined>) {
}, },
paid: () => { paid: () => {
const connected = new Set(providers().connected) const connected = new Set(providers().connected)
return [ const paid = [
...Iterable.filter( ...Iterable.filter(
providers().all, providers().all,
([id]) => ([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)), (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
), ),
] ]
return paid
}, },
} }
} }
+1 -1
View File
@@ -119,7 +119,7 @@ export default function LegacyLayout(props: ParentProps) {
const permission = usePermission() const permission = usePermission()
const navigate = useNavigate() const navigate = useNavigate()
setNavigate(navigate) setNavigate(navigate)
const providers = useProviders() const providers = useProviders(() => undefined)
const dialog = useDialog() const dialog = useDialog()
const command = useCommand() const command = useCommand()
const theme = useTheme() const theme = useTheme()
@@ -25,15 +25,16 @@ export function createPromptInputController(input: {
}) { }) {
const layout = useLayout() const layout = useLayout()
const local = useLocal() const local = useLocal()
const providers = useProviders()
const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync()
const providers = useProviders(() => sdk().directory)
const view = layout.view(input.sessionKey) const view = layout.view(input.sessionKey)
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
return createMemo<PromptInputControls>(() => ({ return createMemo<PromptInputControls>(() => {
return {
agents: { agents: {
available: sync().data.agent, available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name), options: local.agent.list().map((agent) => agent.name),
@@ -46,14 +47,17 @@ export function createPromptInputController(input: {
selection: input.model ?? local.model, selection: input.model ?? local.model,
paid: providers.paid().length > 0, paid: providers.paid().length > 0,
loading: loading:
(local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading, (local.agent.visible() && agentsQuery.isLoading) ||
providersQuery.isLoading ||
globalProvidersQuery.isLoading,
}, },
session: { session: {
id: input.sessionID(), id: input.sessionID(),
tabs: layout.tabs(input.sessionKey), tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel, reviewPanel: view.reviewPanel,
}, },
})) }
})
} }
export function createPromptProjectControls() { export function createPromptProjectControls() {
@@ -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).pathname).toBe("/session/ses_1/permissions/permission_1")
expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other") 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
View File
@@ -401,6 +401,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
providerID: value.integrationID, providerID: value.integrationID,
auth: { type: "api", key: value.key }, auth: { type: "api", key: value.key },
}) })
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
}, },
}, },
oauth: { oauth: {
@@ -429,6 +431,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
{ providerID: value.integrationID, method, code: value.code }, { providerID: value.integrationID, method, code: value.code },
{ throwOnError: true }, { throwOnError: true },
) )
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
}, },
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => { status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
const method = Number(value.attemptID.split(":").at(-1)) const method = Number(value.attemptID.split(":").at(-1))
@@ -436,6 +440,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
{ providerID: value.integrationID, method }, { providerID: value.integrationID, method },
{ throwOnError: true }, { throwOnError: true },
) )
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
return located( return located(
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } }, { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
value.location, value.location,