From fe20cb1a83bda0df538b5446db4673118129c048 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Wed, 30 Sep 2026 12:10:17 +0800 Subject: [PATCH 1/3] fix(connections): guide default model selection and commit it atomically Generated-by: Codex --- apps/desktop/renderer-architecture.json | 1 - .../connection-settings-locale-render.test.ts | 144 ++++++++- .../runtime-host-connections-ipc-main.test.ts | 15 + apps/desktop/src/main/runtime-host-client.ts | 5 +- .../main/runtime-host-connections-ipc-main.ts | 26 +- apps/desktop/src/preload/bridge-contract.d.ts | 4 +- apps/desktop/src/preload/preload.ts | 7 +- .../connection-default-action.tsx | 278 ++++++++++++++++++ .../features/connection-settings/index.ts | 2 + .../features/connection-settings/ports.ts | 4 +- .../settings-provider-copy.ts | 30 ++ .../create-connection-settings-services.ts | 4 +- .../src/renderer/settings/providers-panel.tsx | 35 +-- packages/core/src/runtime-policy.ts | 2 + .../connection-catalog-codec.ts | 18 +- .../connection-effect-coordinator.test.ts | 49 +++ .../connection-effects-protocol.test.ts | 22 +- .../runtime-policy-coordinator.test.ts | 13 + .../src/protocol/connection-effects.ts | 15 +- packages/runtime-host/src/protocol/index.ts | 3 +- .../src/protocol/runtime-policy.ts | 1 + .../server/connection-effect-coordinator.ts | 6 +- .../__tests__/runtime-policy-stores.test.ts | 57 ++++ packages/storage/src/runtime-policy-stores.ts | 3 +- .../connection-catalog-document.ts | 72 +++-- .../storage/src/runtime-policy/coordinator.ts | 2 + .../storage/src/runtime-policy/operations.ts | 1 + 27 files changed, 747 insertions(+), 72 deletions(-) create mode 100644 apps/desktop/src/renderer/features/connection-settings/connection-default-action.tsx diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c7e27ba7e5..a03f96a440 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -2498,7 +2498,6 @@ "./provider-connection-status": 1, "./provider-display": 1, "./provider-oauth-section": 1, - "./settings-error-copy": 1, "./settings-route-focus": 1, "./settings-route-header": 1, "@astryxdesign/core": 1, diff --git a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts index aa541c5b40..8d93af32ea 100644 --- a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts +++ b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts @@ -37,6 +37,10 @@ import type { RuntimeHostManagementServices } from '../../renderer/features/runt // Keep renderer implementations and their asset imports out of the main compilation graph. interface RenderModules { + ProvidersPanel: ComponentType<{ + bridge: ConnectionsBridge; + initialConnectionSlug?: string; + }>; ConnectionDetail: ComponentType<{ bridge: ConnectionsBridge; connection: ProjectedLlmConnection; @@ -97,6 +101,7 @@ before(async () => { await build({ stdin: { contents: [ + "export { ProvidersPanel } from './settings/providers-panel';", "export { ConnectionDetail } from './settings/provider-connection-detail';", "export { RuntimeHostSettingsTarget } from './settings/runtime-host-settings-target';", "export { AddProviderForm } from './settings/provider-add-form';", @@ -524,6 +529,141 @@ function deferred() { return { promise, resolve: resolvePromise, reject: rejectPromise }; } +test('setting a default with one enabled model commits directly', async () => { + const harness = installRenderer(); + const connection = { ...relayConnection(), defaultModel: '' }; + const calls: unknown[] = []; + const bridge = connectionDetailBridge({ + hasSecret: async () => true, + getSnapshot: async () => ({ connections: [connection], defaultConnection: 'relay-a', chatModelChoices: [] }), + setDefault: async (identity, modelId) => { calls.push([identity, modelId]); }, + }); + await harness.render('zh-CN', providersPanel(bridge, connection.slug)); + await clickText(harness.document, '设为默认'); + assert.deepEqual(calls, [[{ connectionId: connection.connectionId, slug: connection.slug }, connection.enabledModelIds![0]]]); + assert.equal(harness.document.querySelectorAll('dialog[open]').length, 0); +}); + +test('a connection with no enabled models asks for a choice and cancel writes nothing', async () => { + const harness = installRenderer(); + const connection = { ...relayConnection(), defaultModel: '', enabledModelIds: [] }; + const bridge = connectionDetailBridge({ + hasSecret: async () => true, + getSnapshot: async () => ({ connections: [connection], defaultConnection: 'relay-a', chatModelChoices: [] }), + }); + await harness.render('zh-CN', providersPanel(bridge, connection.slug)); + await clickText(harness.document, '设为默认'); + const dialog = harness.document.querySelector('dialog[open]'); + assert.ok(dialog); + assert.match(dialog.textContent!, /这个连接尚未启用模型/); + const confirm = [...dialog.querySelectorAll('button')].find(button => button.textContent === '设为默认'); + assert.ok(confirm?.disabled); + await clickText(dialog, '取消'); + assert.equal(harness.document.querySelectorAll('dialog[open]').length, 0); +}); + +test('choosing an unenabled default keeps the dialog on failure and updates the badge only on success', async () => { + const harness = installRenderer(); + let connection = { ...relayConnection(), defaultModel: '', enabledModelIds: [] as string[] }; + let defaultConnection = 'relay-a'; + const calls: unknown[] = []; + const bridge = connectionDetailBridge({ + hasSecret: async () => true, + getSnapshot: async () => ({ connections: [connection], defaultConnection, chatModelChoices: [] }), + setDefault: async (identity, modelId) => { + calls.push([identity, modelId]); + if (calls.length === 1) throw new Error('DEFAULT_CONNECTION_CHANGED'); + connection = { ...connection, defaultModel: modelId!, enabledModelIds: [modelId!] }; + defaultConnection = connection.slug; + }, + }); + await harness.render('zh-CN', providersPanel(bridge, connection.slug)); + await clickText(harness.document, '设为默认'); + const selector = harness.document.querySelector('dialog[open] [role="combobox"]'); + assert.ok(selector); + await act(async () => selector.click()); + const option = [...harness.document.querySelectorAll('[role="option"]')].find(element => element.textContent?.includes('gpt-5.6-sol-joybuilder')); + assert.ok(option); + await act(async () => option.click()); + await clickText(harness.document.querySelector('dialog[open]')!, '启用并设为默认'); + assert.equal(defaultConnection, 'relay-a'); + assert.match(harness.document.querySelector('dialog[open]')!.textContent!, /连接配置已更新/); + await clickText(harness.document.querySelector('dialog[open]')!, '启用并设为默认'); + assert.equal(calls.length, 2); + assert.deepEqual(calls[1], [{ connectionId: connection.connectionId, slug: connection.slug }, 'gpt-5.6-sol-joybuilder']); + assert.equal(defaultConnection, connection.slug); + assert.equal(harness.document.querySelectorAll('dialog[open]').length, 0); + assert.equal(harness.document.querySelector('.settingsActionSlotBadge')?.textContent, '默认'); +}); + +test('fetching choices in the default dialog preserves the selection and offers every discovered model', async () => { + const harness = installRenderer(); + let connection: ProjectedLlmConnection = { ...relayConnection(), defaultModel: '', enabledModelIds: [], models: [], catalogEntries: [] }; + const bridge = connectionDetailBridge({ + hasSecret: async () => true, + getSnapshot: async () => ({ connections: [connection], defaultConnection: 'relay-a', chatModelChoices: [] }), + fetchModels: async (identity, options) => { + assert.deepEqual(identity, { connectionId: connection.connectionId, slug: connection.slug }); + assert.deepEqual(options, { preserveSelection: true }); + connection = { ...connection, models: [{ id: 'discovered-first' }, { id: 'discovered-second' }], + catalogEntries: ['discovered-first', 'discovered-second'].map(id => ({ ...relayConnection().catalogEntries[0]!, id, isDefault: false })), + }; + return { models: connection.models!, source: 'fetched' }; + }, + }); + await harness.render('zh-CN', providersPanel(bridge, connection.slug)); + await clickText(harness.document, '设为默认'); + await clickText(harness.document.querySelector('dialog[open]')!, '获取模型'); + const selector = harness.document.querySelector('dialog[open] [role="combobox"]'); + assert.ok(selector); + await act(async () => selector.click()); + const offered = [...harness.document.querySelectorAll('[role="option"]')].map(option => option.textContent); + assert.ok(offered.includes('discovered-first')); + assert.ok(offered.includes('discovered-second')); + await clickText(harness.document.querySelector('dialog[open]')!, '取消'); + assert.deepEqual(connection.enabledModelIds, []); +}); + +test('multiple enabled models require a choice, and an empty catalog offers model setup', async () => { + const harness = installRenderer(); + let connection: ProjectedLlmConnection = { ...relayConnection(), defaultModel: '', + enabledModelIds: ['first', 'second'], + catalogEntries: ['first', 'second'].map(id => ({ ...relayConnection().catalogEntries[0]!, id, isDefault: false })), + }; + const bridge = connectionDetailBridge({ + hasSecret: async () => true, + getSnapshot: async () => ({ connections: [connection], defaultConnection: 'relay-a', chatModelChoices: [] }), + fetchModels: async () => { + connection = { ...relayConnection(), defaultModel: '', enabledModelIds: [] }; + return { models: connection.models!, source: 'fetched' }; + }, + }); + await harness.render('zh-CN', providersPanel(bridge, connection.slug)); + await clickText(harness.document, '设为默认'); + assert.match(harness.document.querySelector('dialog[open]')!.textContent!, /选择这个连接用于新任务/); + await clickText(harness.document.querySelector('dialog[open]')!, '取消'); + connection = { ...connection, connectionId: 'empty-relay', slug: 'empty-relay', models: [], catalogEntries: [], enabledModelIds: [] }; + // A new panel instance reads the empty connection instead of reusing the old route. + await harness.render('zh-CN', providersPanel(bridge, connection.slug, 'empty')); + await clickText(harness.document, '设为默认'); + assert.match(harness.document.querySelector('dialog[open]')!.textContent!, /请先获取或手动添加模型/); + await clickText(harness.document.querySelector('dialog[open]')!, '手动添加模型'); + assert.match(harness.document.querySelector('dialog[open]')!.textContent!, /模型 ID/); +}); + +function providersPanel(bridge: ConnectionsBridge, slug: string, key?: string) { + return createElement(components.RuntimeHostSettingsTarget, { + host: { profileId: 'local', hostId: 'host-local' }, + children: createElement(components.ProvidersPanel, { key, bridge, initialConnectionSlug: slug }), + }); +} + +async function clickText(container: ParentNode, label: string) { + const button = [...container.querySelectorAll('button')].find(button => button.textContent === label); + assert.ok(button, `missing button: ${label}`); + await act(async () => button.click()); +} + function relayConnection(): ProjectedLlmConnection { const modelId = 'gpt-5.6-sol-joybuilder'; return { @@ -611,8 +751,8 @@ function installRenderer() { }) as unknown as CSSStyleDeclaration; Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} }); Object.assign(window.HTMLElement.prototype, { - showModal(this: HTMLElement) { this.setAttribute('open', ''); }, - close(this: HTMLElement) { this.removeAttribute('open'); }, + showModal(this: HTMLElement & { open: boolean }) { this.open = true; this.setAttribute('open', ''); }, + close(this: HTMLElement & { open: boolean }) { this.open = false; this.removeAttribute('open'); }, }); Object.assign(globalThis, { document, window, matchMedia, getComputedStyle, diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 256d7a37b1..849fed3bc5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -653,11 +653,17 @@ test('renderer service saves through IPC into the canonical catalog and reads it })) }; }, updateConnection: (expected: UpdateCatalogConnectionInput['expected'], changes: UpdateCatalogConnectionInput['changes']) => stores.connectionCatalog.update({ expected, changes }), + setDefaultConnectionTarget: (expectedCatalogRevision: number, target: { connectionId: string; modelId: string } | null, enableModel?: boolean) => + stores.connectionCatalog.setDefaultTarget({ expectedCatalogRevision, target, ...(enableModel === undefined ? {} : { enableModel }) }), } as never, emitConnectionListChanged() {}, }); const host = { profileId: 'profile', hostId: 'host' }; const services = createDesktopConnectionSettingsServices(() => ({ connections: { + setDefault: (identity: unknown, target: unknown, modelId: unknown) => { + assert.deepEqual(target, host); + return handlers.get('connections:setDefault')!({}, identity, modelId); + }, update: (identity: unknown, patch: unknown, target: unknown) => { assert.deepEqual(target, host); return handlers.get('connections:update')!({}, identity, patch); @@ -680,6 +686,15 @@ test('renderer service saves through IPC into the canonical catalog and reads it assert.deepEqual((await stores.connectionCatalog.getSnapshot()).connections[0], saved); await services.update(identity, { modelOverride: { modelId: 'manual', expected: value, value: {} } }); assert.deepEqual((await services.getSnapshot()).connections[0]?.modelOverrides, { other: { vision: true }, manual: {} }); + await services.update(identity, { enabledModelIds: [] }); + const beforeDefault = await stores.connectionCatalog.getSnapshot(); + await services.setDefault(identity, 'manual'); + const defaulted = await stores.connectionCatalog.getSnapshot(); + assert.equal(defaulted.revision, beforeDefault.revision + 1); + assert.deepEqual(defaulted.defaultTarget, { connectionId: identity.connectionId, modelId: 'manual' }); + assert.deepEqual((await services.getSnapshot()).connections[0]?.enabledModelIds, ['manual']); + await assert.rejects(services.setDefault(identity, 'missing'), /DEFAULT_MODEL_UNAVAILABLE/); + assert.deepEqual(await stores.connectionCatalog.getSnapshot(), defaulted); } finally { await owner.close(); await rm(root, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index b5cb1aac62..e10f95b3ac 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -506,10 +506,12 @@ export class DesktopRuntimeHostClient { setDefaultConnectionTarget( expectedCatalogRevision: number, target: OperationInput<"connection.catalog.set-default-target">["target"], + enableModel?: boolean, ): Promise> { return this.request("connection.catalog.set-default-target", { expectedCatalogRevision, target, + ...(enableModel === undefined ? {} : { enableModel }), }); } @@ -540,8 +542,9 @@ export class DesktopRuntimeHostClient { fetchConnectionModels( connectionId: string, + preserveSelection?: boolean, ): Promise> { - return this.request("connection.models.fetch", { connectionId }); + return this.request("connection.models.fetch", { connectionId, ...(preserveSelection === undefined ? {} : { preserveSelection }) }); } testConnection( diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index eb58a35709..ef2a3246ab 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -134,15 +134,24 @@ export function registerRuntimeHostConnectionsIpc( return { names: result.names } satisfies SavedRequestHeaders; }, ); - deps.ipcMain.handle('connections:setDefault', async (_event, identity: unknown) => { + deps.ipcMain.handle('connections:setDefault', async (_event, identity: unknown, modelId?: unknown) => { const catalog = await snapshot(); + const connection = identity === null ? null : requireConnectionIdentity(catalog, identity); + if (modelId !== undefined && (typeof modelId !== 'string' || !modelId.trim() || !connection)) { + throw new Error('Invalid default model selection'); + } const target = identity === null ? null - : defaultTargetForConnection(requireConnectionIdentity(catalog, identity)); - requireCommitted( - await deps.client.setDefaultConnectionTarget(catalog.revision, target), - 'set default Connection', + : typeof modelId === 'string' + ? { connectionId: connection!.connectionId, modelId } + : defaultTargetForConnection(connection!); + const enableModel = typeof modelId === 'string' && !connection!.enabledModelIds.includes(modelId); + const result = await deps.client.setDefaultConnectionTarget( + catalog.revision, target, enableModel ? true : undefined, ); + if (result.kind === 'revision_conflict') throw new Error('DEFAULT_CONNECTION_CHANGED'); + if (result.kind === 'invalid_default_target') throw new Error('DEFAULT_MODEL_UNAVAILABLE'); + requireCommitted(result, 'set default Connection'); deps.emitConnectionListChanged(); }); deps.ipcMain.handle('connections:setDefaultBySlug', async (_event, slug: unknown) => { @@ -334,9 +343,12 @@ export function registerRuntimeHostConnectionsIpc( throw new Error('Unable to delete Connection: connection_stale'); } }); - deps.ipcMain.handle('connections:fetchModels', async (_event, identity: unknown) => { + deps.ipcMain.handle('connections:fetchModels', async (_event, identity: unknown, options?: { preserveSelection?: unknown }) => { const current = requireConnectionIdentity(await snapshot(), identity); - const result = await deps.client.fetchConnectionModels(current.connectionId); + if (options?.preserveSelection !== undefined && typeof options.preserveSelection !== 'boolean') { + throw new Error('Invalid model discovery selection policy'); + } + const result = await deps.client.fetchConnectionModels(current.connectionId, options?.preserveSelection); if (result.kind !== 'committed') { throw new Error(`Unable to fetch Connection models: ${result.kind}`); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 52c50ef981..6cca3cf51a 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1566,7 +1566,7 @@ export interface MakaBridge { }; connections: { getSnapshot(sessionId?: string, host?: DesktopRuntimeHostRef): Promise; - setDefault(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise; + setDefault(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef, modelId?: string): Promise; setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise; create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise; verifyOnboarding( @@ -1580,7 +1580,7 @@ export interface MakaBridge { update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise; delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; - fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise>; + fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef, options?: { preserveSelection?: boolean }): Promise>; hasSecret(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; getRequestHeaders(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; setRequestHeaders( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index eb4c9e8885..9eee67caeb 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3180,11 +3180,12 @@ const makaBridge = { ? invokeRuntimeHostForSession('connections:getSnapshot', sessionId) : invokeSelectedRuntimeHost(host, 'connections:getSnapshot'); }, - setDefault(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise { + setDefault(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef, modelId?: string): Promise { return invokeSelectedRuntimeHost( host, typeof connection === 'string' ? 'connections:setDefaultBySlug' : 'connections:setDefault', connection, + modelId, ); }, setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise { @@ -3213,8 +3214,8 @@ const makaBridge = { opts, ); }, - fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise> { - return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection); + fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef, options?: { preserveSelection?: boolean }): Promise> { + return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection, options); }, hasSecret(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:hasSecret', connection); diff --git a/apps/desktop/src/renderer/features/connection-settings/connection-default-action.tsx b/apps/desktop/src/renderer/features/connection-settings/connection-default-action.tsx new file mode 100644 index 0000000000..460019800e --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/connection-default-action.tsx @@ -0,0 +1,278 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { Banner, Button, HStack, Selector, Text, VStack } from '@astryxdesign/core'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { + providerSupportsModelDiscovery, + type ProjectedLlmConnection, +} from '@maka/core/llm-connections'; +import { useMountedRef, useUiLocale } from '@maka/ui'; +import { getProviderSettingsCopy } from './settings-provider-copy.js'; +import { providerPanelActionErrorMessage } from './provider-panel-shared.js'; +import type { ConnectionsBridge } from './ports.js'; +import { AddModelDialog } from './provider-add-model-dialog.js'; +import type { ModelOverride } from '@maka/core/model-thinking'; +import { useRuntimeHostSettingsErrorReporter } from '../../application/contracts/settings-presentation/runtime-host-settings-target.js'; + +export function ConnectionDefaultAction(props: { + connection: ProjectedLlmConnection; + bridge: ConnectionsBridge; + onChanged(): Promise; + children(action: ReactNode): ReactNode; +}) { + const { connection } = props; + const locale = useUiLocale(); + const { panel: copy, detail } = getProviderSettingsCopy(locale); + const reportError = useRuntimeHostSettingsErrorReporter(); + const mounted = useMountedRef(); + const inFlight = useRef(false); + const [busy, setBusy] = useState(false); + const [open, setOpen] = useState(false); + const [addingModel, setAddingModel] = useState(false); + const [modelId, setModelId] = useState(''); + const [error, setError] = useState(null); + const models = connection.catalogEntries.filter((entry) => entry.canUseAsChatDefault); + const enabled = models.filter((entry) => connection.enabledModelIds?.includes(entry.id)); + const choices = enabled.length > 0 ? enabled : models; + const selected = choices.find((entry) => entry.id === modelId); + const needsEnable = selected !== undefined && !connection.enabledModelIds?.includes(selected.id); + const identity = { connectionId: connection.connectionId, slug: connection.slug }; + + function close() { + if (inFlight.current) return; + setOpen(false); + setModelId(''); + setError(null); + } + + async function setDefault(id: string) { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + setError(null); + let committed = false; + try { + await props.bridge.setDefault(identity, id); + committed = true; + if (!mounted.current) return; + setOpen(false); + setModelId(''); + await props.onChanged(); + } catch (cause) { + if (!mounted.current) return; + const raw = cause instanceof Error ? cause.message : String(cause); + const message = /DEFAULT_CONNECTION_CHANGED/.test(raw) + ? copy.defaultConnectionChanged + : /DEFAULT_MODEL_UNAVAILABLE/.test(raw) + ? copy.defaultModelUnavailable + : /Connection has no enabled model/.test(raw) + ? copy.enableDefaultModelHelp + : providerPanelActionErrorMessage(cause, locale); + if (open && !committed) setError(message); + else reportError(committed ? detail.refreshFailed : copy.setDefaultFailed, message); + } finally { + inFlight.current = false; + if (mounted.current) setBusy(false); + } + } + + async function fetchModels() { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + setError(null); + try { + await props.bridge.fetchModels(identity, { preserveSelection: true }); + if (mounted.current) await props.onChanged(); + } catch (cause) { + if (mounted.current) setError(providerPanelActionErrorMessage(cause, locale)); + } finally { + inFlight.current = false; + if (mounted.current) setBusy(false); + } + } + + async function addModel(id: string, profile: ModelOverride): Promise { + if (inFlight.current) return false; + inFlight.current = true; + setBusy(true); + let saved = false; + try { + // Adding a candidate does not enable it: the default confirmation owns + // that choice and commits the selection together with the default. + await props.bridge.update(identity, { + modelOverride: { modelId: id, expected: null, value: profile }, + }); + saved = true; + if (!mounted.current) return true; + await props.onChanged(); + if (!mounted.current) return true; + setModelId(id); + setOpen(true); + return true; + } catch (cause) { + if (mounted.current) + reportError( + saved ? detail.refreshFailed : detail.saveModelsFailed, + providerPanelActionErrorMessage(cause, locale), + ); + return saved; + } finally { + inFlight.current = false; + if (mounted.current) setBusy(false); + } + } + + return ( + <> + {props.children( +