diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index f13db45eabb..4130353eeb1 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -626,15 +626,10 @@ "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 4, "bridgePaths": {}, - "environmentCapabilities": { - "window.addEventListener": 1, - "window.removeEventListener": 1 - }, + "environmentCapabilities": {}, "hookCalls": { "useAppShellCommands": 1, "useHotkeys": 1, - "useLayoutEffect": 2, - "useRef": 1, "useUiLocale": 1 }, "lifecycleMethods": {}, @@ -642,6 +637,7 @@ "actionFactories": [], "dependencyPaths": { "./app-shell-command-actions": 1, + "./application/contracts/settings-presentation/index.js": 1, "./features/overlays/index.js": 1, "./locales/shell-remaining-copy.js": 1, "./settings/settings-modal": 1, @@ -650,8 +646,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 7, - "nonTriviaTokens": 858 + "importSpecifiers": 5, + "nonTriviaTokens": 780 }, "src/renderer/app-shell.tsx": { "importDeclarations": 34, @@ -2460,6 +2456,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../application/contracts/settings-presentation/index.js": 1, "../features/connection-settings": 2, "./password-input": 1, "./provider-display": 1, @@ -2981,6 +2978,7 @@ "dependencyPaths": { "../../shared/settings-ownership.js": 1, "../application/contracts/session-catalog/catalog-sessions.js": 1, + "../application/contracts/settings-presentation/index.js": 1, "../application/contracts/shell-layout-contract.js": 1, "../browser-storage": 1, "../features/connection-settings": 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 8d93af32ea5..fa31237be50 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 @@ -36,7 +36,26 @@ import type { ConnectionsBridge } from '../../renderer/features/connection-setti import type { RuntimeHostManagementServices } from '../../renderer/features/runtime-host-management/index.js'; // Keep renderer implementations and their asset imports out of the main compilation graph. +interface DetailController { + enabledModelIds: string[]; + hasEnabledModelChanges: boolean; + savingEnabledModels: boolean; + runTest(): Promise; + updateEnabledModels(id: string): void; + saveEnabledModels(): Promise; + cancelEnabledModels(): void; + addDeclaredModel(id: string, profile: Record): Promise; +} interface RenderModules { + SettingsNavigationGuard: ComponentType<{ children: ReactNode | ((navigate: (action: () => void) => Promise) => ReactNode) }>; + SettingsDismissal: ComponentType<{ onClose(): void; children: ReactNode }>; + useConnectionDetail(props: { + bridge: ConnectionsBridge; + connection: ProjectedLlmConnection; + isDefault: boolean; + onChanged(): Promise; + onDeleted(): Promise; + }): DetailController; ProvidersPanel: ComponentType<{ bridge: ConnectionsBridge; initialConnectionSlug?: string; @@ -101,6 +120,8 @@ before(async () => { await build({ stdin: { contents: [ + "export { SettingsNavigationGuard, SettingsDismissal } from './application/contracts/settings-presentation/index';", + "export { useConnectionDetail } from './settings/use-connection-detail';", "export { ProvidersPanel } from './settings/providers-panel';", "export { ConnectionDetail } from './settings/provider-connection-detail';", "export { RuntimeHostSettingsTarget } from './settings/runtime-host-settings-target';", @@ -664,6 +685,266 @@ async function clickText(container: ParentNode, label: string) { await act(async () => button.click()); } +async function modelDraftHarness(options: { + update: ConnectionsBridge['update']; + onChanged?: () => Promise; + testConnection?: ConnectionsBridge['test']; +}) { + const renderer = installRenderer(); + let connection = relayConnection(); + let controller!: DetailController; + const bridge = connectionDetailBridge({ hasSecret: async () => true, update: options.update, ...(options.testConnection ? { test: options.testConnection } : {}) }); + function Probe() { + controller = components.useConnectionDetail({ + bridge, connection, isDefault: true, + onChanged: options.onChanged ?? (async () => {}), onDeleted: async () => {}, + }); + return null; + } + const render = () => renderer.render('en', createElement(components.RuntimeHostSettingsTarget, { + host: { profileId: 'local', hostId: 'host-local' }, children: createElement(Probe), + })); + await render(); + return { + document: renderer.document, + get controller() { return controller; }, + async refresh(next: ProjectedLlmConnection) { connection = next; await render(); }, + }; +} + +test('model selection batches rapid edits into one explicit save and locks only during save/refresh', async () => { + const writes: string[][] = []; + const write = deferred(); + const refresh = deferred(); + let refreshCount = 0; + const harness = await modelDraftHarness({ + update: async (_identity, patch) => { writes.push(patch.enabledModelIds!); return write.promise; }, + onChanged: () => { refreshCount += 1; return refresh.promise; }, + }); + const original = relayConnection().defaultModel; + await act(async () => { + // Calls intentionally share one React batch: no closure may lose a click. + harness.controller.updateEnabledModels('model-a'); + harness.controller.updateEnabledModels('model-b'); + harness.controller.updateEnabledModels(original); + }); + assert.deepEqual(harness.controller.enabledModelIds, ['model-a', 'model-b']); + assert.equal(harness.controller.hasEnabledModelChanges, true); + assert.deepEqual(writes, []); + let pending!: Promise; + await act(async () => { pending = harness.controller.saveEnabledModels(); }); + assert.deepEqual(writes, [['model-a', 'model-b']]); + await act(async () => { + harness.controller.updateEnabledModels('ignored-during-save'); + assert.equal(await harness.controller.saveEnabledModels(), false); + }); + assert.deepEqual(harness.controller.enabledModelIds, ['model-a', 'model-b']); + const confirmed = { ...relayConnection(), defaultModel: 'model-a', enabledModelIds: ['model-a', 'model-b'] }; + await act(async () => { write.resolve(confirmed); }); + assert.equal(refreshCount, 1); + assert.equal(harness.controller.savingEnabledModels, true); + assert.equal(harness.controller.hasEnabledModelChanges, false); + await act(async () => { refresh.resolve(); assert.equal(await pending, true); }); + await harness.refresh(confirmed); + assert.deepEqual(harness.controller.enabledModelIds, ['model-a', 'model-b']); + assert.equal(harness.controller.savingEnabledModels, false); +}); + +test('model selection survives external refresh and failed save, then cancels to the latest snapshot', async () => { + const harness = await modelDraftHarness({ update: async () => { throw new Error('Host unavailable'); } }); + await act(async () => { harness.controller.updateEnabledModels('model-a'); }); + const refreshed = { ...relayConnection(), defaultModel: 'external-model', enabledModelIds: ['external-model'] }; + await harness.refresh(refreshed); + assert.deepEqual(harness.controller.enabledModelIds, [relayConnection().defaultModel, 'model-a']); + await act(async () => { assert.equal(await harness.controller.saveEnabledModels(), false); }); + assert.deepEqual(harness.controller.enabledModelIds, [relayConnection().defaultModel, 'model-a']); + assert.equal(harness.controller.hasEnabledModelChanges, true); + await act(async () => { harness.controller.cancelEnabledModels(); }); + assert.deepEqual(harness.controller.enabledModelIds, ['external-model']); + assert.equal(harness.controller.hasEnabledModelChanges, false); +}); + +test('external refresh updates the saved baseline while retaining edits back to the old selection', async () => { + const original = relayConnection(); + const writes: string[][] = []; + const harness = await modelDraftHarness({ + update: async (_identity, patch) => { writes.push(patch.enabledModelIds!); return original; }, + }); + await act(async () => { harness.controller.updateEnabledModels('model-b'); }); + await harness.refresh({ ...original, defaultModel: 'model-c', enabledModelIds: ['model-c'] }); + await act(async () => { harness.controller.updateEnabledModels('model-b'); }); + assert.deepEqual(harness.controller.enabledModelIds, [original.defaultModel]); + assert.equal(harness.controller.hasEnabledModelChanges, true); + await act(async () => { assert.equal(await harness.controller.saveEnabledModels(), true); }); + assert.deepEqual(writes, [[original.defaultModel]]); + assert.equal(harness.controller.hasEnabledModelChanges, false); +}); + +test('connection testing follows an external saved selection while the old selection remains drafted', async () => { + const original = relayConnection(); + const harness = await modelDraftHarness({ + update: unexpectedCall, + testConnection: async () => ({ ok: true, modelTested: 'model-c', latencyMs: 5 }), + }); + await act(async () => { harness.controller.updateEnabledModels('model-b'); }); + await harness.refresh({ ...original, defaultModel: 'model-c', enabledModelIds: ['model-c'] }); + await act(async () => { harness.controller.updateEnabledModels('model-b'); }); + await act(async () => { await harness.controller.runTest(); }); + assert.ok(harness.document.body.textContent.includes(`Connected · ${original.name}`)); + assert.ok(!harness.document.body.textContent.includes(`Connection works · ${original.name}`)); + assert.deepEqual(harness.controller.enabledModelIds, [original.defaultModel]); + assert.equal(harness.controller.hasEnabledModelChanges, true); +}); + +test('successful model save remains acknowledged if the subsequent refresh fails', async () => { + const confirmed = { ...relayConnection(), enabledModelIds: ['model-a'], defaultModel: 'model-a' }; + const harness = await modelDraftHarness({ + update: async () => confirmed, + onChanged: async () => { throw new Error('refresh unavailable'); }, + testConnection: async () => ({ ok: true, modelTested: 'model-a', latencyMs: 5 }), + }); + await act(async () => { + harness.controller.updateEnabledModels(relayConnection().defaultModel); + harness.controller.updateEnabledModels('model-a'); + }); + await act(async () => { assert.equal(await harness.controller.saveEnabledModels(), true); }); + assert.deepEqual(harness.controller.enabledModelIds, ['model-a']); + assert.equal(harness.controller.hasEnabledModelChanges, false); + await act(async () => { await harness.controller.runTest(); }); + assert.ok(harness.document.body.textContent.includes(`Connected · ${confirmed.name}`)); + assert.ok(!harness.document.body.textContent.includes(`Connection works · ${confirmed.name}`)); +}); + +test('toggling a model back restores the clean selection and a connection switch isolates drafts', async () => { + const writes: unknown[] = []; + const harness = await modelDraftHarness({ update: async (...args) => { writes.push(args); return relayConnection(); } }); + await act(async () => { + harness.controller.updateEnabledModels('model-a'); + harness.controller.updateEnabledModels('model-a'); + }); + assert.equal(harness.controller.hasEnabledModelChanges, false); + await act(async () => { assert.equal(await harness.controller.saveEnabledModels(), true); }); + assert.deepEqual(writes, []); + await act(async () => { harness.controller.updateEnabledModels('model-a'); }); + await harness.refresh({ ...relayConnection(), connectionId: 'another-connection', defaultModel: 'another-model', enabledModelIds: ['another-model'] }); + assert.deepEqual(harness.controller.enabledModelIds, ['another-model']); + assert.equal(harness.controller.hasEnabledModelChanges, false); +}); + +test('testing a connection uses the saved selection when a different model is only drafted', async () => { + const original = relayConnection(); + const harness = await modelDraftHarness({ + update: unexpectedCall, + testConnection: async () => ({ ok: true, modelTested: original.defaultModel, latencyMs: 5 }), + }); + await act(async () => { + harness.controller.updateEnabledModels(original.defaultModel); + harness.controller.updateEnabledModels('unsaved-model'); + }); + await act(async () => { await harness.controller.runTest(); }); + assert.ok(harness.document.body.textContent.includes(`Connected · ${original.name}`)); + assert.ok(!harness.document.body.textContent.includes(`Connection works · ${original.name}`)); + assert.deepEqual(harness.controller.enabledModelIds, ['unsaved-model']); + assert.equal(harness.controller.hasEnabledModelChanges, true); +}); + +test('adding a custom model preserves an existing selection draft without persisting it', async () => { + const original = relayConnection(); + const confirmed = { ...original, enabledModelIds: [original.defaultModel, 'custom-model'] }; + const writes: Parameters[1][] = []; + const harness = await modelDraftHarness({ + update: async (_identity, patch) => { writes.push(patch); return confirmed; }, + }); + await act(async () => { + harness.controller.updateEnabledModels(original.defaultModel); + harness.controller.updateEnabledModels('model-a'); + }); + await act(async () => { assert.equal(await harness.controller.addDeclaredModel('custom-model', {}), true); }); + assert.deepEqual(writes, [{ modelOverride: { modelId: 'custom-model', expected: null, value: {}, enable: true } }]); + assert.deepEqual(harness.controller.enabledModelIds, ['model-a', 'custom-model']); + assert.equal(harness.controller.hasEnabledModelChanges, true); + await harness.refresh(confirmed); + assert.deepEqual(harness.controller.enabledModelIds, ['model-a', 'custom-model']); + await act(async () => { harness.controller.cancelEnabledModels(); }); + assert.deepEqual(harness.controller.enabledModelIds, [original.defaultModel, 'custom-model']); +}); + +test('adding a custom model acknowledges its selection even if catalog refresh fails', async () => { + const original = relayConnection(); + const confirmed = { ...original, enabledModelIds: [original.defaultModel, 'custom-model'] }; + const harness = await modelDraftHarness({ + update: async () => confirmed, + onChanged: async () => { throw new Error('refresh unavailable'); }, + }); + await act(async () => { assert.equal(await harness.controller.addDeclaredModel('custom-model', {}), true); }); + assert.deepEqual(harness.controller.enabledModelIds, [original.defaultModel, 'custom-model']); + assert.equal(harness.controller.hasEnabledModelChanges, false); +}); + +test('provider detail protects an unsaved selection on Back and saves only from the footer', async () => { + const harness = installRenderer(); + // Toast confirmation advances its mount/close phases on animation frames. + Object.assign(window, { + requestAnimationFrame: (callback: FrameRequestCallback) => { queueMicrotask(() => callback(0)); return 1; }, + cancelAnimationFrame() {}, + }); + let connection = relayConnection(); + const writes: string[][] = []; + const bridge = connectionDetailBridge({ + hasSecret: async () => true, + getSnapshot: async () => ({ connections: [connection], defaultConnection: connection.slug, chatModelChoices: [] }), + update: async (_identity, patch) => { + writes.push(patch.enabledModelIds!); + connection = { ...connection, defaultModel: '', enabledModelIds: patch.enabledModelIds! }; + return connection; + }, + }); + let closed = 0; + let navigated = 0; + await harness.render('en', createElement(components.SettingsNavigationGuard, { + children: (navigate) => createElement(components.SettingsDismissal, { + onClose: () => { closed += 1; }, + children: createElement('div', null, + createElement('button', { onClick: () => void navigate(() => { navigated += 1; }) }, 'Other section'), + providersPanel(bridge, connection.slug)), + }), + })); + const toggle = async () => { + const input = harness.document.querySelector('input[role="switch"]'); + assert.ok(input); + const key = Object.keys(input).find((candidate) => candidate.startsWith('__reactProps$')); + assert.ok(key); + const props = (input as unknown as Record)[key] as { + onChange(event: { target: { checked: boolean }; defaultPrevented: boolean }): void; + }; + await act(async () => props.onChange({ target: { checked: false }, defaultPrevented: false })); + }; + await toggle(); + assert.deepEqual(writes, []); + assert.match(harness.document.querySelector('.providerModelsSaveBar')!.textContent!, /Unsaved model selection/); + const back = harness.document.querySelector('button[aria-label="Back to model connections"]'); + assert.ok(back); + await act(async () => back.click()); + assert.match(harness.document.querySelector('dialog[open]')!.textContent!, /Discard model selection changes/); + await clickText(harness.document.querySelector('dialog[open]')!, 'Cancel'); + assert.ok(harness.document.querySelector('[data-maka-contract="connection-detail"]')); + await clickText(harness.document, 'Other section'); + assert.equal(navigated, 0); + await clickText(harness.document.querySelector('dialog[open]')!, 'Cancel'); + const escape = new Event('keydown', { bubbles: true, cancelable: true }); + Object.defineProperty(escape, 'key', { value: 'Escape' }); + await act(async () => { window.dispatchEvent(escape); }); + assert.equal(closed, 0); + await clickText(harness.document.querySelector('dialog[open]')!, 'Cancel'); + const bar = harness.document.querySelector('.providerModelsSaveBar')!; + await clickText(bar, 'Save'); + assert.deepEqual(writes, [[]]); + await toggle(); + await act(async () => back.click()); + await clickText(harness.document.querySelector('dialog[open]')!, 'Discard changes'); + assert.equal(harness.document.querySelector('[data-maka-contract="connection-detail"]'), null); +}); + function relayConnection(): ProjectedLlmConnection { const modelId = 'gpt-5.6-sol-joybuilder'; return { diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx index f1bb3f01380..cee0b00a5e8 100644 --- a/apps/desktop/src/renderer/app-shell-overlays.tsx +++ b/apps/desktop/src/renderer/app-shell-overlays.tsx @@ -17,7 +17,8 @@ * under the License. */ -import { lazy, Suspense, useLayoutEffect, useRef, type ReactNode } from 'react'; +import { SettingsNavigationGuard, SettingsDismissal } from './application/contracts/settings-presentation/index.js'; +import { lazy, Suspense, type ReactNode } from 'react'; import type { ThemePalette, ThemePreference } from '@maka/core/settings'; import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; import type { UiLocalePreference } from '@maka/core/ui-locale'; @@ -55,23 +56,13 @@ export function SettingsOverlay({ onClose, children }: { onClose(): void; children: ReactNode; }) { - const closeRef = useRef(onClose); - useLayoutEffect(() => { - closeRef.current = onClose; - }); - useLayoutEffect(() => { - function onKeyDown(event: KeyboardEvent) { - if ( - event.key.toLowerCase() !== 'escape' || event.defaultPrevented || - event.ctrlKey || event.metaKey || event.altKey - ) return; - event.preventDefault(); - closeRef.current(); - } - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, []); - return }>{children}; + return ( + + + }>{children} + + + ); } /** diff --git a/apps/desktop/src/renderer/application/contracts/settings-presentation/settings-navigation.tsx b/apps/desktop/src/renderer/application/contracts/settings-presentation/settings-navigation.tsx index 3f5bfa89e57..00bfcc88c3d 100644 --- a/apps/desktop/src/renderer/application/contracts/settings-presentation/settings-navigation.tsx +++ b/apps/desktop/src/renderer/application/contracts/settings-presentation/settings-navigation.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import { createContext, useContext, useEffect, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react'; import type { SettingsSection } from '@maka/core/settings'; export interface SettingsNavigation { @@ -38,3 +38,50 @@ export function SettingsNavigationProvider(props: { }) { return {props.children}; } + +export type SettingsNavigate = (action: () => void) => Promise; +type LeaveCheck = () => Promise; +const SettingsLeaveContext = createContext | null>(null); + +/** The current editor protects explicit navigation without owning routing. */ +export function SettingsNavigationGuard({ children }: { + children: ReactNode | ((navigate: SettingsNavigate) => ReactNode); +}) { + const inherited = useContext(SettingsLeaveContext); + const local = useRef(null); + const check = inherited ?? local; + const navigate: SettingsNavigate = async (action) => { + if (!check.current || await check.current()) action(); + }; + return + {typeof children === 'function' ? children(navigate) : children} + ; +} + +export function SettingsLeaveCheck({ onLeave }: { onLeave: LeaveCheck }) { + const check = useContext(SettingsLeaveContext); + useEffect(() => { + if (!check) return; + check.current = onLeave; + return () => { if (check.current === onLeave) check.current = null; }; + }, [check, onLeave]); + return null; +} + +/** Escape uses the same editor check as the Settings navigation buttons. */ +export function SettingsDismissal({ onClose, children }: { onClose(): void; children: ReactNode }) { + const check = useContext(SettingsLeaveContext); + const closeRef = useRef(onClose); + useLayoutEffect(() => { closeRef.current = onClose; }); + useLayoutEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key.toLowerCase() !== 'escape' || event.defaultPrevented || + event.ctrlKey || event.metaKey || event.altKey) return; + event.preventDefault(); + void (async () => { if (!check?.current || await check.current()) closeRef.current(); })(); + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [check]); + return children; +} diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index c82acfca634..a3b3cd91637 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -171,6 +171,7 @@ const zhCopy = { detail: { delete: '删除', cancel: '取消', deleteUnused: '不再需要,删除连接', deleteFailed: '删除模型连接失败', credentialReadFailed: '读取模型凭据状态失败', refreshFailed: '刷新模型连接失败', + modelSelection: '模型选择', modelsUnsaved: '有未保存的模型选择', modelsSaved: '模型选择已保存', discardModels: '放弃模型选择修改?', discardModelsHelp: '离开将放弃未保存的选择。你也可以取消并返回保存。', discardModelsConfirm: '放弃修改', saveFailed: '保存模型连接失败', saveModelsFailed: '保存启用模型失败', modelKey: '模型密钥', pasteModelKey: '粘贴模型密钥', getModelKey: '获取模型密钥', saving: '保存中…', advancedRequest: '高级请求设置', advancedRequestHelp: '为这个连接的 HTTP 请求添加请求头和额外 JSON 请求体。请求头值作为凭据保存在本机。', @@ -384,6 +385,7 @@ const zhTwCopy = { deviceCode: '登入碼:', delete: '刪除', cancel: '取消', deleteUnused: '不再需要,刪除連線', deleteFailed: '刪除模型連線失敗', credentialReadFailed: '讀取模型憑據狀態失敗', refreshFailed: '重新整理模型連線失敗', + modelSelection: '模型選擇', modelsUnsaved: '有未儲存的模型選擇', modelsSaved: '模型選擇已儲存', discardModels: '放棄模型選擇修改?', discardModelsHelp: '離開將放棄未儲存的選擇。你也可以取消並返回儲存。', discardModelsConfirm: '放棄修改', saveFailed: '儲存模型連線失敗', saveModelsFailed: '儲存啟用模型失敗', modelKey: '模型金鑰', pasteModelKey: '貼上模型金鑰', getModelKey: '取得模型金鑰', saving: '儲存中…', advancedRequest: '高階請求設定', advancedRequestHelp: '為這個連線的 HTTP 請求新增請求頭和額外 JSON 請求體。請求頭值作為憑據儲存在本機。', @@ -597,6 +599,7 @@ const enCopy: ProviderSettingsCopy = { detail: { delete: 'Delete', cancel: 'Cancel', deleteUnused: 'Delete unused connection', deleteFailed: 'Failed to delete model connection', credentialReadFailed: 'Failed to read model credential status', refreshFailed: 'Failed to refresh model connection', + modelSelection: 'Model selection', modelsUnsaved: 'Unsaved model selection', modelsSaved: 'Model selection saved', discardModels: 'Discard model selection changes?', discardModelsHelp: 'Leaving discards your unsaved selection. You can cancel and return to save it.', discardModelsConfirm: 'Discard changes', saveFailed: 'Failed to save model connection', saveModelsFailed: 'Failed to save enabled models', modelKey: 'Model key', pasteModelKey: 'Paste model key', getModelKey: 'Get model key', saving: 'Saving…', advancedRequest: 'Advanced request settings', advancedRequestHelp: 'Add HTTP headers and extra JSON request-body fields for this connection. Header values stay in the local credential vault.', diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 680136c3995..c033a477dcd 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -62,6 +62,7 @@ import { providerPanelActionErrorMessage, type CredentialPresenceStatus, } from '../features/connection-settings'; +import { SettingsLeaveCheck } from '../application/contracts/settings-presentation/index.js'; import { useConnectionDetail, type ConnectionDetailProps, @@ -192,6 +193,10 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { savedBaseUrl, save, updateEnabledModels, + saveEnabledModels, + cancelEnabledModels, + hasEnabledModelChanges, + savingEnabledModels, addDeclaredModel, modelParameters, hasModelChanges, @@ -425,17 +430,27 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { size="sm" value={enabled} isDisabled={allActionsBusy} - changeAction={() => - updateEnabledModels( - enabled ? enabledModelIds.filter((existing) => existing !== id) : [...enabledModelIds, id], - ) - } + onChange={() => updateEnabledModels(id)} /> ); } return ( + { + if (savingEnabledModels) return false; + if (!hasEnabledModelChanges) return true; + const discard = await toast.confirm({ + title: copy.discardModels, + description: copy.discardModelsHelp, + confirmLabel: copy.discardModelsConfirm, + cancelLabel: copy.cancel, + destructive: true, + }); + if (!mounted.current || !discard) return false; + cancelEnabledModels(); + return true; + }} /> {retired && ( )} @@ -836,6 +851,17 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {