From 74b5b94550a47d9879a0ed74cb65d00192f12415 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:57:52 +0800 Subject: [PATCH 01/24] fix(desktop): remove model and thinking-level success toasts Generated-by: Codex --- ...app-shell-session-settings-actions.test.ts | 105 ++++++++---------- .../app-shell-session-settings-actions.ts | 47 +------- apps/desktop/src/renderer/app-shell.tsx | 2 - .../src/renderer/locales/shell-copy.ts | 32 ------ 4 files changed, 48 insertions(+), 138 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 3b980403cf..ff9a9a5c94 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -19,8 +19,6 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { LlmConnection } from '@maka/core/llm-connections'; -import type { StoredMessage } from '@maka/core/session'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; import { createAppShellSessionSettingsActions } from '../../renderer/app-shell-session-settings-actions.js'; @@ -57,8 +55,6 @@ function session(id: string): DesktopSessionSummary { function createHarness(options: { confirm?: () => Promise; - connections?: LlmConnection[]; - messages?: StoredMessage[]; permissionModeResult?: 'ask' | 'bypass'; } = {}) { const activeIdRef = { current: 'session-a' as string | undefined }; @@ -72,6 +68,8 @@ function createHarness(options: { const errors: string[] = []; const errorTargets: Array<{ sessionId: string } | undefined> = []; const successes: Array<{ title: string; description?: string }> = []; + const savedModels: Array<{ llmConnectionSlug: string; model: string }> = []; + let refreshCount = 0; const newTaskPermissionModes: string[] = []; const modelResult = deferred(); const thinkingResult = deferred(); @@ -104,12 +102,13 @@ function createHarness(options: { const actions = createAppShellSessionSettingsActions({ uiLocale: 'zh', activeIdRef, - connections: options.connections ?? ([{ slug: 'e2e', name: 'E2E' }] as LlmConnection[]), - messages: options.messages ?? [], pendingPermissionModeChangesRef: { current: new Set() }, pendingSessionModelChangesRef: { current: pending }, - refreshSessions: async () => sessions, - saveComposerDefaults: () => undefined, + refreshSessions: async () => { + refreshCount += 1; + return sessions; + }, + saveComposerDefaults: ({ model }) => void savedModels.push(model), sessionsRef, setNewTaskPermissionMode: (mode) => void newTaskPermissionModes.push(mode), setPendingPermissionModeBySession: () => undefined, @@ -139,6 +138,10 @@ function createHarness(options: { pending, pendingBySession, permissionCalls, + get refreshCount() { + return refreshCount; + }, + savedModels, sessionsRef, thinkingCalls, thinkingResult, @@ -181,6 +184,10 @@ describe('AppShell session settings actions', () => { assert.equal(switched, true); assert.deepEqual(harness.permissionCalls, ['session-a:bypass']); + assert.deepEqual(harness.successes, [{ + title: '已切到 完全权限', + description: '本地工具直接访问你的文件和网络,不经 Maka 的保护层。', + }]); }); it('does not report success when the Host returns another permission mode', async () => { @@ -229,18 +236,8 @@ describe('AppShell session settings actions', () => { await modelChange; }); - it('confirms both sides of a successful model change', async () => { - const harness = createHarness({ - messages: [{ - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 1, - text: 'done', - modelId: 'claude-haiku', - }], - }); - + it('keeps a successful model change silent', async () => { + const harness = createHarness(); const modelChange = harness.actions.setSessionModel({ llmConnectionSlug: 'e2e', model: 'claude-opus', @@ -248,49 +245,26 @@ describe('AppShell session settings actions', () => { harness.modelResult.resolve({ ...session('session-a'), model: 'claude-opus' }); await modelChange; - assert.deepEqual(harness.successes, [ - { - title: '已切换当前任务模型', - description: 'claude-haiku → claude-opus', - }, - ]); - }); - - it('falls back to the configured model for a fresh conversation', async () => { - const harness = createHarness(); - - const modelChange = harness.actions.setSessionModel({ + assert.deepEqual(harness.modelCalls, ['session-a']); + assert.deepEqual(harness.savedModels, [{ llmConnectionSlug: 'e2e', model: 'claude-opus', - }); - harness.modelResult.resolve({ ...session('session-a'), model: 'claude-opus' }); - await modelChange; - - assert.equal(harness.successes[0]?.description, 'claude-sonnet → claude-opus'); + }]); + assert.equal(harness.refreshCount, 1); + assert.deepEqual(harness.successes, []); }); - it('includes connection names when a switch rebinds the connection', async () => { - const harness = createHarness({ - connections: [ - { slug: 'e2e', name: 'Primary' }, - { slug: 'relay', name: 'Relay' }, - ] as LlmConnection[], - }); + it('keeps repeated successful thinking-level changes silent', async () => { + const harness = createHarness(); + harness.thinkingResult.resolve(session('session-a')); - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'relay', - model: 'claude-sonnet', - }); - harness.modelResult.resolve({ - ...session('session-a'), - llmConnectionSlug: 'relay', - }); - await modelChange; + await harness.actions.setSessionThinkingLevel('high'); + await harness.actions.setSessionThinkingLevel('xhigh'); + await harness.actions.setSessionThinkingLevel('low'); - assert.equal( - harness.successes[0]?.description, - 'claude-sonnet (Primary) → claude-sonnet (Relay)', - ); + assert.deepEqual(harness.thinkingCalls, ['session-a', 'session-a', 'session-a']); + assert.equal(harness.refreshCount, 3); + assert.deepEqual(harness.successes, []); }); it('keeps another session available while the first session mutation is pending', async () => { @@ -331,6 +305,21 @@ describe('AppShell session settings actions', () => { assert.equal(harness.pendingBySession['session-a'], undefined); }); + it('preserves model failure feedback', async () => { + const harness = createHarness(); + + const modelChange = harness.actions.setSessionModel({ + llmConnectionSlug: 'e2e', + model: 'claude-opus', + }); + harness.modelResult.reject(new Error('fixture failure')); + await modelChange; + + assert.deepEqual(harness.errors, ['切换模型失败']); + assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); + assert.deepEqual(harness.successes, []); + }); + it('releases the session owner after a failed mutation so the next action can run', async () => { const harness = createHarness(); @@ -340,7 +329,7 @@ describe('AppShell session settings actions', () => { assert.equal(harness.pending.has('session-a'), false); assert.equal(harness.pendingBySession['session-a'], undefined); - assert.equal(harness.errors.length, 1); + assert.deepEqual(harness.errors, ['切换思考级别失败']); assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); const modelChange = harness.actions.setSessionModel({ diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 3140d55a26..35b6def5f9 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -18,12 +18,7 @@ */ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { LlmConnection } from '@maka/core/llm-connections'; import type { PermissionMode } from '@maka/core/permission'; -import { - latestAssistantModelId, - type StoredMessage, -} from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { UiLocale } from '@maka/core/ui-locale'; import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; @@ -58,8 +53,6 @@ export interface AppShellSessionSettingsActions { export function createAppShellSessionSettingsActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; - connections: readonly LlmConnection[]; - messages: readonly StoredMessage[]; pendingPermissionModeChangesRef: RefBox>; pendingSessionModelChangesRef: RefBox>; refreshSessions: () => Promise; @@ -76,8 +69,6 @@ export function createAppShellSessionSettingsActions(deps: { const { uiLocale, activeIdRef, - connections, - messages, pendingPermissionModeChangesRef, pendingSessionModelChangesRef, refreshSessions, @@ -97,19 +88,6 @@ export function createAppShellSessionSettingsActions(deps: { return next; } - function modelLabel(connectionSlug: string, model: string): string { - const connection = connections.find((entry) => entry.slug === connectionSlug); - const displayName = connection?.models?.find((entry) => entry.id === model)?.displayName?.trim(); - return displayName || model; - } - - function modelEndpointLabel(connectionSlug: string, model: string, includeConnection: boolean): string { - const label = modelLabel(connectionSlug, model); - if (!includeConnection) return label; - const connection = connections.find((entry) => entry.slug === connectionSlug); - return `${label} (${connection?.name ?? connectionSlug})`; - } - async function setPermissionMode(mode: PermissionMode): Promise { if (mode !== 'ask' && mode !== 'bypass') return false; const sessionId = activeIdRef.current; @@ -169,8 +147,6 @@ export function createAppShellSessionSettingsActions(deps: { async function setSessionModel(input: { llmConnectionSlug: string; model: string }) { const sessionId = activeIdRef.current; if (!sessionId) return; - const previous = sessionsRef.current.find((session) => session.id === sessionId); - const lastUsedModel = latestAssistantModelId(messages); if (pendingSessionModelChangesRef.current.has(sessionId)) return; pendingSessionModelChangesRef.current.add(sessionId); setPendingSessionModelBySession((current) => ({ @@ -178,25 +154,7 @@ export function createAppShellSessionSettingsActions(deps: { [sessionId]: true, })); try { - const next = await window.maka.sessions.setModel(sessionId, input); - if (activeIdRef.current === sessionId) { - const connectionChanged = previous?.llmConnectionSlug !== next.llmConnectionSlug; - const to = modelEndpointLabel(next.llmConnectionSlug, next.model, connectionChanged); - const previousModel = lastUsedModel ?? previous?.model; - toastApi.success( - copy.modelSwitchedTitle, - previous && previousModel - ? copy.modelSwitchedDescription( - modelEndpointLabel( - previous.llmConnectionSlug, - previousModel, - connectionChanged, - ), - to, - ) - : to, - ); - } + await window.maka.sessions.setModel(sessionId, input); saveComposerDefaults({ model: input }); await refreshSessions(); } catch (error) { @@ -227,9 +185,6 @@ export function createAppShellSessionSettingsActions(deps: { })); try { await window.maka.sessions.setThinkingLevel(sessionId, level); - if (activeIdRef.current === sessionId) { - toastApi.success(copy.thinkingUpdatedTitle, level ? copy.thinkingLabels[level] : copy.thinkingDefault); - } await refreshSessions(); } catch (error) { if (activeIdRef.current === sessionId) { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index c599125ae6..ffcd85d608 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -908,8 +908,6 @@ function AppShellContent({ } = useStableActions(createAppShellSessionSettingsActions, { uiLocale, activeIdRef, - connections, - messages, pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, refreshSessions, diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index a858a39464..b0f5076b59 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -27,7 +27,6 @@ import { type ChatDefaultPermissionMode, type SettingsSection } from '@maka/core import { type SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; -import { type ThinkingLevel } from '@maka/core/model-thinking'; import { type GoalStatus } from '@maka/core/goal'; export const STATIC_COMMAND_IDS = [ @@ -333,13 +332,8 @@ type ShellCopy = { permissionSwitched(label: string): string; permissionFailedTitle: string; permissionFallback: string; - modelSwitchedTitle: string; - modelSwitchedDescription(from: string, to: string): string; modelFailedTitle: string; modelFallback: string; - thinkingUpdatedTitle: string; - thinkingDefault: string; - thinkingLabels: Record; thinkingFailedTitle: string; thinkingFallback: string; }; @@ -997,21 +991,8 @@ const SHELL_COPY_BY_LOCALE = { permissionSwitched: (label: string) => `已切到 ${label}`, permissionFailedTitle: '切换权限模式失败', permissionFallback: '权限模式暂时无法切换,请稍后重试。', - modelSwitchedTitle: '已切换当前任务模型', - modelSwitchedDescription: (from, to) => `${from} → ${to}`, modelFailedTitle: '切换模型失败', modelFallback: '模型暂时无法切换,请稍后重试。', - thinkingUpdatedTitle: '已更新思考级别', - thinkingDefault: '默认', - thinkingLabels: { - off: '关', - minimal: '最少', - low: '低', - medium: '中', - high: '高', - xhigh: '超高', - max: '最高', - }, thinkingFailedTitle: '切换思考级别失败', thinkingFallback: '思考级别暂时无法切换,请稍后重试。', }, @@ -1523,21 +1504,8 @@ const SHELL_COPY_BY_LOCALE = { permissionSwitched: (label: string) => `Switched to ${label}`, permissionFailedTitle: 'Could not change permission mode', permissionFallback: 'The permission mode could not be changed. Try again later.', - modelSwitchedTitle: 'Task model changed', - modelSwitchedDescription: (from, to) => `${from} → ${to}`, modelFailedTitle: 'Could not change model', modelFallback: 'The model could not be changed. Try again later.', - thinkingUpdatedTitle: 'Thinking level updated', - thinkingDefault: 'Default', - thinkingLabels: { - off: 'Off', - minimal: 'Minimal', - low: 'Low', - medium: 'Medium', - high: 'High', - xhigh: 'Extra high', - max: 'Maximum', - }, thinkingFailedTitle: 'Could not change thinking level', thinkingFallback: 'The thinking level could not be changed. Try again later.', }, From 3686d2aafaf59bb97487fa7689bab7b360a01c69 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:58:27 +0800 Subject: [PATCH 02/24] fix(desktop): make model setting changes instant Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 89 +++ .../app-shell-first-send-cleanup.test.ts | 1 + ...app-shell-session-settings-actions.test.ts | 191 +---- .../app-shell-session-ui-state.test.ts | 2 - .../model-settings-visual-contract.test.ts | 92 +++ .../session-model-settings-intent.test.ts | 665 ++++++++++++++++++ .../src/renderer/app-shell-chat-actions.ts | 6 + .../desktop/src/renderer/app-shell-effects.ts | 2 - .../app-shell-session-settings-actions.ts | 70 -- .../renderer/app-shell-session-ui-state.ts | 6 +- apps/desktop/src/renderer/app-shell.tsx | 79 ++- .../use-app-shell-session-ui-reads.ts | 3 - .../use-app-shell-session-workspace.ts | 1 - .../use-session-model-settings-intent.ts | 302 ++++++++ .../src/renderer/use-shell-chat-model.ts | 29 +- .../__tests__/chat-model-switcher.test.tsx | 136 ++++ packages/ui/src/chat-model-switcher.tsx | 18 +- packages/ui/src/chat-view.tsx | 1 - packages/ui/src/composer.tsx | 7 +- packages/ui/src/conversation-copy.ts | 5 +- 20 files changed, 1398 insertions(+), 307 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts create mode 100644 apps/desktop/src/renderer/use-session-model-settings-intent.ts create mode 100644 packages/ui/src/__tests__/chat-model-switcher.test.tsx diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 7b0edd27b9..9d9a5d59c9 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -80,6 +80,14 @@ function createMessageState() { }; } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + function createActionsDeps() { return { uiLocale: 'en' as const, @@ -104,6 +112,7 @@ function createActionsDeps() { setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, + settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, newChatModel: null, @@ -119,6 +128,86 @@ function createActionsDeps() { const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; describe('busy-raced send settlement', () => { + it('presents ordinary processing while model settings settle before send', async () => { + const settlement = deferred(); + const calls: string[] = []; + let sessionSendCalls = 0; + const turnState = createTurnState(); + const restoreWindow = installWindow({ + sessions: { + send: async (_sessionId: string, command: { turnId: string }) => { + sessionSendCalls += 1; + return { + ok: true, + turnId: command.turnId, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + setLiveTurnBySession: turnState.setLiveTurnBySession, + settleSessionModelSettings: async () => { + calls.push('settle'); + return settlement.promise; + }, + }); + + const sending = actions.send('hello'); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(turnState.liveTurnBySession['session-a']?.phase, 'waiting'); + assert.deepEqual(calls, ['settle']); + assert.equal(sessionSendCalls, 0); + + settlement.resolve(true); + assert.equal(await sending, true); + assert.equal(sessionSendCalls, 1); + } finally { + restoreWindow(); + } + }); + + it('disarms processing and skips send when model settings fail to settle', async () => { + let sessionSendCalls = 0; + const turnState = createTurnState(); + const restoreWindow = installWindow({ + sessions: { + send: async () => { + sessionSendCalls += 1; + return { + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + setLiveTurnBySession: turnState.setLiveTurnBySession, + settleSessionModelSettings: async () => { + assert.equal(turnState.liveTurnBySession['session-a']?.phase, 'waiting'); + return false; + }, + }); + + assert.equal(await actions.send('hello'), false); + assert.equal(turnState.liveTurnBySession['session-a'], undefined); + assert.equal(sessionSendCalls, 0); + } finally { + restoreWindow(); + } + }); + it('a steered send on an existing session disarms its turn and shows no optimistic message', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 967d777dc9..21080564e3 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -105,6 +105,7 @@ function createActionsDeps() { setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, + settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, newChatModel: null, diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index ff9a9a5c94..d8afc52ac1 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -22,16 +22,6 @@ import { describe, it } from 'node:test'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; import { createAppShellSessionSettingsActions } from '../../renderer/app-shell-session-settings-actions.js'; -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((next, fail) => { - resolve = next; - reject = fail; - }); - return { promise, reject, resolve }; -} - function session(id: string): DesktopSessionSummary { return { id, @@ -56,23 +46,16 @@ function session(id: string): DesktopSessionSummary { function createHarness(options: { confirm?: () => Promise; permissionModeResult?: 'ask' | 'bypass'; + permissionFailure?: Error; } = {}) { const activeIdRef = { current: 'session-a' as string | undefined }; - const sessions = [session('session-a'), session('session-b')]; - const sessionsRef = { current: sessions }; - const pending = new Set(); - const pendingBySession: Record = {}; - const modelCalls: string[] = []; + const sessionsRef = { current: [session('session-a'), session('session-b')] }; const permissionCalls: string[] = []; - const thinkingCalls: string[] = []; const errors: string[] = []; const errorTargets: Array<{ sessionId: string } | undefined> = []; const successes: Array<{ title: string; description?: string }> = []; - const savedModels: Array<{ llmConnectionSlug: string; model: string }> = []; - let refreshCount = 0; const newTaskPermissionModes: string[] = []; - const modelResult = deferred(); - const thinkingResult = deferred(); + let refreshCount = 0; Object.defineProperty(globalThis, 'window', { configurable: true, @@ -81,19 +64,12 @@ function createHarness(options: { sessions: { setPermissionMode: async (sessionId: string, mode: 'ask' | 'bypass') => { permissionCalls.push(`${sessionId}:${mode}`); + if (options.permissionFailure) throw options.permissionFailure; return { ...session(sessionId), permissionMode: options.permissionModeResult ?? mode, }; }, - setModel: async (sessionId: string) => { - modelCalls.push(sessionId); - return modelResult.promise; - }, - setThinkingLevel: async (sessionId: string) => { - thinkingCalls.push(sessionId); - return thinkingResult.promise; - }, }, }, }, @@ -103,20 +79,13 @@ function createHarness(options: { uiLocale: 'zh', activeIdRef, pendingPermissionModeChangesRef: { current: new Set() }, - pendingSessionModelChangesRef: { current: pending }, refreshSessions: async () => { refreshCount += 1; - return sessions; + return sessionsRef.current; }, - saveComposerDefaults: ({ model }) => void savedModels.push(model), sessionsRef, setNewTaskPermissionMode: (mode) => void newTaskPermissionModes.push(mode), setPendingPermissionModeBySession: () => undefined, - setPendingSessionModelBySession: (update) => { - const next = update(pendingBySession); - for (const key of Object.keys(pendingBySession)) delete pendingBySession[key]; - Object.assign(pendingBySession, next); - }, toastApi: { success: (title, description) => successes.push({ title, description }), error: (title, _description, _details, target) => { @@ -132,19 +101,12 @@ function createHarness(options: { activeIdRef, errors, errorTargets, - modelCalls, - modelResult, newTaskPermissionModes, - pending, - pendingBySession, permissionCalls, get refreshCount() { return refreshCount; }, - savedModels, sessionsRef, - thinkingCalls, - thinkingResult, successes, }; } @@ -154,9 +116,7 @@ describe('AppShell session settings actions', () => { const harness = createHarness(); harness.activeIdRef.current = undefined; - const switched = await harness.actions.setPermissionMode('bypass'); - - assert.equal(switched, true); + assert.equal(await harness.actions.setPermissionMode('bypass'), true); assert.deepEqual(harness.newTaskPermissionModes, ['bypass']); assert.deepEqual(harness.permissionCalls, []); }); @@ -170,9 +130,7 @@ describe('AppShell session settings actions', () => { }, }); - const switched = await harness.actions.setPermissionMode('bypass'); - - assert.equal(switched, false); + assert.equal(await harness.actions.setPermissionMode('bypass'), false); assert.equal(confirmations, 1); assert.deepEqual(harness.permissionCalls, []); }); @@ -180,10 +138,9 @@ describe('AppShell session settings actions', () => { it('reports a confirmed bypass switch as successful', async () => { const harness = createHarness(); - const switched = await harness.actions.setPermissionMode('bypass'); - - assert.equal(switched, true); + assert.equal(await harness.actions.setPermissionMode('bypass'), true); assert.deepEqual(harness.permissionCalls, ['session-a:bypass']); + assert.equal(harness.refreshCount, 1); assert.deepEqual(harness.successes, [{ title: '已切到 完全权限', description: '本地工具直接访问你的文件和网络,不经 Maka 的保护层。', @@ -193,9 +150,7 @@ describe('AppShell session settings actions', () => { it('does not report success when the Host returns another permission mode', async () => { const harness = createHarness({ permissionModeResult: 'ask' }); - const switched = await harness.actions.setPermissionMode('bypass'); - - assert.equal(switched, false); + assert.equal(await harness.actions.setPermissionMode('bypass'), false); assert.deepEqual(harness.permissionCalls, ['session-a:bypass']); }); @@ -212,132 +167,16 @@ describe('AppShell session settings actions', () => { permissionMode: 'bypass', }]; - const switched = await harness.actions.setPermissionMode('bypass'); - - assert.equal(switched, true); + assert.equal(await harness.actions.setPermissionMode('bypass'), true); assert.equal(confirmations, 0); assert.deepEqual(harness.permissionCalls, []); }); - it('blocks a thinking-level mutation while the same session model mutation is pending', async () => { - const harness = createHarness(); - - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - await harness.actions.setSessionThinkingLevel('high'); - - assert.deepEqual(harness.modelCalls, ['session-a']); - assert.deepEqual(harness.thinkingCalls, []); - assert.equal(harness.pendingBySession['session-a'], true); - - harness.modelResult.resolve(session('session-a')); - await modelChange; - }); - - it('keeps a successful model change silent', async () => { - const harness = createHarness(); - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - harness.modelResult.resolve({ ...session('session-a'), model: 'claude-opus' }); - await modelChange; - - assert.deepEqual(harness.modelCalls, ['session-a']); - assert.deepEqual(harness.savedModels, [{ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }]); - assert.equal(harness.refreshCount, 1); - assert.deepEqual(harness.successes, []); - }); - - it('keeps repeated successful thinking-level changes silent', async () => { - const harness = createHarness(); - harness.thinkingResult.resolve(session('session-a')); - - await harness.actions.setSessionThinkingLevel('high'); - await harness.actions.setSessionThinkingLevel('xhigh'); - await harness.actions.setSessionThinkingLevel('low'); - - assert.deepEqual(harness.thinkingCalls, ['session-a', 'session-a', 'session-a']); - assert.equal(harness.refreshCount, 3); - assert.deepEqual(harness.successes, []); - }); - - it('keeps another session available while the first session mutation is pending', async () => { - const harness = createHarness(); - - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - harness.activeIdRef.current = 'session-b'; - const thinkingChange = harness.actions.setSessionThinkingLevel('high'); - - assert.deepEqual(harness.modelCalls, ['session-a']); - assert.deepEqual(harness.thinkingCalls, ['session-b']); - assert.deepEqual(harness.pending, new Set(['session-a', 'session-b'])); - - harness.thinkingResult.resolve(session('session-b')); - await thinkingChange; - harness.modelResult.resolve(session('session-a')); - await modelChange; - }); - - it('blocks a model mutation while the same session thinking mutation is pending', async () => { - const harness = createHarness(); - - const thinkingChange = harness.actions.setSessionThinkingLevel('high'); - await harness.actions.setSessionModel({ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - - assert.deepEqual(harness.thinkingCalls, ['session-a']); - assert.deepEqual(harness.modelCalls, []); - assert.equal(harness.pendingBySession['session-a'], true); + it('preserves localized permission failure feedback and its Session target', async () => { + const harness = createHarness({ permissionFailure: new Error('fixture failure') }); - harness.thinkingResult.resolve(session('session-a')); - await thinkingChange; - assert.equal(harness.pendingBySession['session-a'], undefined); - }); - - it('preserves model failure feedback', async () => { - const harness = createHarness(); - - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - harness.modelResult.reject(new Error('fixture failure')); - await modelChange; - - assert.deepEqual(harness.errors, ['切换模型失败']); - assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); - assert.deepEqual(harness.successes, []); - }); - - it('releases the session owner after a failed mutation so the next action can run', async () => { - const harness = createHarness(); - - const thinkingChange = harness.actions.setSessionThinkingLevel('high'); - harness.thinkingResult.reject(new Error('fixture failure')); - await thinkingChange; - - assert.equal(harness.pending.has('session-a'), false); - assert.equal(harness.pendingBySession['session-a'], undefined); - assert.deepEqual(harness.errors, ['切换思考级别失败']); + assert.equal(await harness.actions.setPermissionMode('bypass'), false); + assert.deepEqual(harness.errors, ['切换权限模式失败']); assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); - - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - assert.deepEqual(harness.modelCalls, ['session-a']); - harness.modelResult.resolve(session('session-a')); - await modelChange; }); }); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 5771c5243c..aa788bf556 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -71,7 +71,6 @@ function seededState(): AppShellSessionUiState { keep: [boundaryRequest('keep')], }, pendingPermissionModeBySession: { drop: true, keep: true }, - pendingSessionModelBySession: { drop: true, keep: true }, }; } @@ -184,7 +183,6 @@ describe('app shell session UI state controller', () => { assert.deepEqual(Object.keys(next.liveTurnBySession), ['keep']); assert.deepEqual(Object.keys(next.interactionBySession), ['keep']); assert.deepEqual(Object.keys(next.pendingPermissionModeBySession), ['keep']); - assert.deepEqual(Object.keys(next.pendingSessionModelBySession), ['keep']); }); it('keeps state identity for no-op map updates and only replaces the selected map', () => { diff --git a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts new file mode 100644 index 0000000000..e74ae4175d --- /dev/null +++ b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts @@ -0,0 +1,92 @@ +/* + * 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 assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +function readFirst(candidates: URL[]): string { + const sourceUrl = candidates.find((candidate) => existsSync(candidate)); + assert.ok(sourceUrl, `missing source: ${candidates.map(String).join(', ')}`); + return readFileSync(sourceUrl, 'utf8'); +} + +const chatViewSource = readFirst([ + new URL('../../../../../packages/ui/src/chat-view.tsx', import.meta.url), +]); +const composerSource = readFirst([ + new URL('../../../../../packages/ui/src/composer.tsx', import.meta.url), +]); +const modelSwitcherSource = readFirst([ + new URL('../../../../../packages/ui/src/chat-model-switcher.tsx', import.meta.url), +]); +const appShellSource = readFirst([ + new URL('../../renderer/app-shell.tsx', import.meta.url), + new URL('../../../src/renderer/app-shell.tsx', import.meta.url), +]); +const modelSettingsIntentSource = readFirst([ + new URL('../../renderer/use-session-model-settings-intent.ts', import.meta.url), + new URL('../../../src/renderer/use-session-model-settings-intent.ts', import.meta.url), +]); +const shellChatModelSource = readFirst([ + new URL('../../renderer/use-shell-chat-model.ts', import.meta.url), + new URL('../../../src/renderer/use-shell-chat-model.ts', import.meta.url), +]); + +test('model setting mutations never own spinner or disabled presentation', () => { + assert.doesNotMatch(chatViewSource, /modelChangePending/); + assert.doesNotMatch(composerSource, /modelChangePending/); + assert.doesNotMatch(modelSwitcherSource, /pending\?: boolean/); + assert.doesNotMatch(modelSwitcherSource, /loading\?: boolean/); + assert.doesNotMatch(modelSwitcherSource, /isLoading:\s*(pending|props\.loading)/); + + assert.match(composerSource, /disabledReason=\{modelSwitcherDisabledReason\}/); + assert.match(composerSource, /disabled=\{Boolean\(modelSwitcherDisabledReason\)\}/); +}); + +test('AppShell projects optimistic settings and gates send on their settlement', () => { + assert.match(appShellSource, /useSessionModelSettingsIntent\(\{/); + assert.match(appShellSource, /projectSessionModelSettings\(/); + assert.match( + appShellSource, + /settleSessionModelSettings:\s*modelSettingsIntent\.settle/, + ); + assert.match(appShellSource, /modelSettingsIntent\.selectModel\(activeId, input\)/); + assert.match( + appShellSource, + /modelSettingsIntent\.selectThinkingLevel\(activeId, level\)/, + ); +}); + +test('optimistic settings stay inside committed model-control state', () => { + assert.match( + modelSettingsIntentSource, + /useLayoutEffect\(\(\) => \{\s*optionsRef\.current = options;\s*\}\);/, + ); + assert.match(appShellSource, /sessionHealthSession:\s*activeSession/); + assert.match( + appShellSource, + /activeModelConnectionSlug=\{\s*activeSessionForModelControls\?\.llmConnectionSlug\s*\}/, + ); + assert.match(shellChatModelSource, /session:\s*sessionHealthSession/); + assert.match( + shellChatModelSource, + /lastTestStatus:\s*sessionHealthConnection\?\.lastTestStatus/, + ); +}); diff --git a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts new file mode 100644 index 0000000000..978647df61 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts @@ -0,0 +1,665 @@ +/* + * 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 assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { SessionSummary } from '@maka/core/session'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { + type SessionModelSettingsIntentController, + type SessionModelSettingsIntentOptions, + type SessionModelTarget, + projectSessionModelSettings, + useSessionModelSettingsIntent, +} from '../../renderer/use-session-model-settings-intent.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + Node: globalThis.Node, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test('publishes a model selection before the Host mutation settles', async () => { + const modelWrite = deferred(); + const harness = await mountIntent({ setModel: async () => modelWrite.promise }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + }); + + assert.deepEqual(harness.controller().overlayBySession['session-a'], { + model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, + thinkingLevel: undefined, + }); +}); + +test('persists the composer default after a model commit succeeds', async () => { + const harness = await mountIntent(); + const target = { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }; + + await act(async () => { + harness.controller().selectModel('session-a', target); + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(harness.savedModels, [target]); +}); + +test('coalesces rapid thinking changes to the latest pending level', async () => { + const first = deferred(); + const writes: Array = []; + const harness = await mountIntent({ + setThinkingLevel: async (_sessionId, level) => { + writes.push(level); + if (writes.length === 1) return first.promise; + return session({ thinkingLevel: level }); + }, + }); + + await act(async () => { + harness.controller().selectThinkingLevel('session-a', 'high'); + harness.controller().selectThinkingLevel('session-a', 'xhigh'); + harness.controller().selectThinkingLevel('session-a', 'low'); + }); + first.resolve(session({ thinkingLevel: 'high' })); + + let settled = false; + await act(async () => { + settled = await harness.controller().settle('session-a'); + }); + + assert.equal(settled, true); + assert.deepEqual(writes, ['high', 'low']); + assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, 'low'); +}); + +test('coalesces rapid model changes to the latest pending model', async () => { + const first = deferred(); + const writes: string[] = []; + const harness = await mountIntent({ + setModel: async (_sessionId, model) => { + writes.push(model.model); + if (writes.length === 1) return first.promise; + return session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }); + }, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.5', + }); + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-terra', + }); + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + }); + first.resolve(session({ llmConnectionSlug: 'openai', model: 'gpt-5.5' })); + + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(writes, ['gpt-5.5', 'gpt-5.6-sol']); + assert.equal( + harness.controller().overlayBySession['session-a']?.model.model, + 'gpt-5.6-sol', + ); +}); + +test('continues with a selection made while the current worker is settling', async () => { + const writes: string[] = []; + let refreshes = 0; + let harness!: Awaited>; + harness = await mountIntent({ + setModel: async (_sessionId, model) => { + writes.push(model.model); + return session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }); + }, + refreshCatalog: async () => { + refreshes += 1; + if (refreshes !== 1) return; + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + }, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.5', + }); + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(writes, ['gpt-5.5', 'gpt-5.6-sol']); + assert.equal( + harness.controller().overlayBySession['session-a']?.model.model, + 'gpt-5.6-sol', + ); +}); + +test('reports failure when a settling-time continuation cannot commit', async () => { + const writes: string[] = []; + let refreshes = 0; + let harness!: Awaited>; + harness = await mountIntent({ + setModel: async (_sessionId, model) => { + writes.push(model.model); + if (writes.length === 2) throw new Error('latest model unavailable'); + return session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }); + }, + refreshCatalog: async () => { + refreshes += 1; + if (refreshes !== 1) return; + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + }, + }); + + let settled = true; + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.5', + }); + settled = await harness.controller().settle('session-a'); + }); + + assert.deepEqual(writes, ['gpt-5.5', 'gpt-5.6-sol']); + assert.equal(settled, false); + assert.equal(harness.modelErrors.length, 1); + assert.equal( + harness.controller().overlayBySession['session-a']?.model.model, + 'gpt-5.5', + ); +}); + +test('commits a model before its latest thinking level', async () => { + const modelWrite = deferred(); + const calls: string[] = []; + const harness = await mountIntent({ + setModel: async (_sessionId, model) => { + calls.push(`model:${model.model}`); + return modelWrite.promise; + }, + setThinkingLevel: async (_sessionId, level) => { + calls.push(`thinking:${level ?? 'default'}`); + return session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + thinkingLevel: level, + }); + }, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + harness.controller().selectThinkingLevel('session-a', 'high'); + }); + assert.deepEqual(calls, ['model:gpt-5.6-sol']); + modelWrite.resolve(session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + thinkingLevel: undefined, + })); + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(calls, ['model:gpt-5.6-sol', 'thinking:high']); + assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, 'high'); +}); + +test('a later model selection resets an in-flight thinking selection', async () => { + const thinkingWrite = deferred(); + const calls: string[] = []; + const harness = await mountIntent({ + setThinkingLevel: async (_sessionId, level) => { + calls.push(`thinking:${level ?? 'default'}`); + return thinkingWrite.promise; + }, + setModel: async (_sessionId, model) => { + calls.push(`model:${model.model}`); + return session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }); + }, + }); + + await act(async () => { + harness.controller().selectThinkingLevel('session-a', 'high'); + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + }); + assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, undefined); + assert.deepEqual(calls, ['thinking:high']); + thinkingWrite.resolve(session({ thinkingLevel: 'high' })); + + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(calls, ['thinking:high', 'model:gpt-5.6-sol']); + assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, undefined); +}); + +test('continues from a stale model failure to the newer desired model', async () => { + const first = deferred(); + const writes: string[] = []; + const harness = await mountIntent({ + setModel: async (_sessionId, model) => { + writes.push(model.model); + if (writes.length === 1) return first.promise; + return session({ llmConnectionSlug: model.llmConnectionSlug, model: model.model }); + }, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.5', + }); + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + }); + first.reject(new Error('superseded failure')); + + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(writes, ['gpt-5.5', 'gpt-5.6-sol']); + assert.deepEqual(harness.modelErrors, []); + assert.equal( + harness.controller().overlayBySession['session-a']?.model.model, + 'gpt-5.6-sol', + ); +}); + +test('does not apply an old-model thinking failure to the same level on a newer model', async () => { + const firstThinkingWrite = deferred(); + const calls: string[] = []; + const harness = await mountIntent({ + setModel: async (_sessionId, model) => { + calls.push(`model:${model.model}`); + return session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }); + }, + setThinkingLevel: async (_sessionId, level) => { + calls.push(`thinking:${level ?? 'default'}`); + if (calls.length === 1) return firstThinkingWrite.promise; + return session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + thinkingLevel: level, + }); + }, + }); + + await act(async () => { + harness.controller().selectThinkingLevel('session-a', 'high'); + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + harness.controller().selectThinkingLevel('session-a', 'high'); + }); + firstThinkingWrite.reject(new Error('old model rejected high')); + + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(calls, [ + 'thinking:high', + 'model:gpt-5.6-sol', + 'thinking:high', + ]); + assert.deepEqual(harness.thinkingErrors, []); + assert.deepEqual(harness.controller().overlayBySession['session-a'], { + model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, + thinkingLevel: 'high', + }); +}); + +test('rolls back and reports a terminal first model failure', async () => { + const modelWrite = deferred(); + const harness = await mountIntent({ + setModel: async () => modelWrite.promise, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + }); + + const settling = harness.controller().settle('session-a'); + modelWrite.reject(new Error('model unavailable')); + await act(async () => assert.equal(await settling, false)); + assert.equal(harness.controller().overlayBySession['session-a'], undefined); + assert.equal(harness.modelErrors.length, 1); + assert.equal(harness.modelErrors[0]?.sessionId, 'session-a'); + assert.deepEqual(harness.savedModels, []); +}); + +test('keeps an earlier model commit when the latest thinking write fails', async () => { + const thinkingWrite = deferred(); + const harness = await mountIntent({ + setModel: async (_sessionId, model) => session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }), + setThinkingLevel: async () => thinkingWrite.promise, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + harness.controller().selectThinkingLevel('session-a', 'high'); + }); + + const settling = harness.controller().settle('session-a'); + thinkingWrite.reject(new Error('thinking unavailable')); + await act(async () => assert.equal(await settling, false)); + + assert.deepEqual(harness.controller().overlayBySession['session-a'], { + model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, + thinkingLevel: undefined, + }); + assert.equal(harness.thinkingErrors.length, 1); +}); + +test('retains a committed overlay when catalog refresh fails', async () => { + const harness = await mountIntent({ + refreshCatalog: async () => { + throw new Error('catalog unavailable'); + }, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + assert.equal(await harness.controller().settle('session-a'), true); + await Promise.resolve(); + }); + + assert.equal( + harness.controller().overlayBySession['session-a']?.model.model, + 'gpt-5.6-sol', + ); +}); + +test('a newer successful catalog revision retires a committed overlay', async () => { + const harness = await mountIntent(); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + assert.equal(await harness.controller().settle('session-a'), true); + }); + assert.ok(harness.controller().overlayBySession['session-a']); + + await harness.render(1, session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + })); + + assert.equal(harness.controller().overlayBySession['session-a'], undefined); +}); + +test('clear prevents a late failure from restoring UI or reporting an error', async () => { + const modelWrite = deferred(); + const harness = await mountIntent({ setModel: async () => modelWrite.promise }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + harness.controller().clear('session-a'); + }); + modelWrite.reject(new Error('late failure')); + + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + await Promise.resolve(); + }); + + assert.equal(harness.controller().overlayBySession['session-a'], undefined); + assert.deepEqual(harness.modelErrors, []); +}); + +test('unmount invalidates late mutation failures', async () => { + const modelWrite = deferred(); + const harness = await mountIntent({ setModel: async () => modelWrite.promise }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + }); + const settling = harness.controller().settle('session-a'); + await harness.unmount(); + modelWrite.reject(new Error('late failure')); + + assert.equal(await settling, false); + assert.deepEqual(harness.modelErrors, []); +}); + +test('projects only model settings and preserves identity without an overlay', () => { + const authoritative = session({ name: 'Keep me', hasUnread: true }); + assert.equal(projectSessionModelSettings(authoritative, undefined), authoritative); + + const projected = projectSessionModelSettings(authoritative, { + model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, + thinkingLevel: 'high', + }); + + assert.deepEqual(projected, { + ...authoritative, + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + thinkingLevel: 'high', + }); +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, reject, resolve }; +} + +function session(overrides: Partial = {}): SessionSummary { + return { + id: 'session-a', + name: 'Session A', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'anthropic', + connectionLocked: true, + model: 'claude-sonnet', + permissionMode: 'ask', + ...overrides, + }; +} + +type IntentBehavior = Omit< + SessionModelSettingsIntentOptions, + 'catalogRevision' | 'readAuthoritative' +>; + +async function mountIntent(overrides: Partial = {}) { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + mountedRoot = createRoot(container); + + let captured: SessionModelSettingsIntentController | undefined; + let authoritative = session(); + const modelErrors: Array<{ sessionId: string; error: unknown }> = []; + const thinkingErrors: Array<{ sessionId: string; error: unknown }> = []; + const savedModels: SessionModelTarget[] = []; + const saveComposerModelOverride = overrides.saveComposerModel; + const behavior: IntentBehavior = { + setModel: async (_sessionId, model) => session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }), + setThinkingLevel: async (_sessionId, level) => session({ thinkingLevel: level }), + refreshCatalog: async () => undefined, + onModelError: (sessionId, error) => modelErrors.push({ sessionId, error }), + onThinkingError: (sessionId, error) => thinkingErrors.push({ sessionId, error }), + ...overrides, + saveComposerModel: (model) => { + savedModels.push(model); + saveComposerModelOverride?.(model); + }, + }; + + const render = async ( + catalogRevision: number, + nextAuthoritative: SessionSummary = authoritative, + ) => { + authoritative = nextAuthoritative; + const options: SessionModelSettingsIntentOptions = { + ...behavior, + catalogRevision, + readAuthoritative: (sessionId) => + sessionId === authoritative.id ? authoritative : undefined, + }; + await act(async () => { + mountedRoot?.render(createElement(Harness, { + options, + capture: (controller) => { + captured = controller; + }, + })); + }); + }; + + await render(0); + + return { + controller: () => { + assert.ok(captured); + return captured; + }, + modelErrors, + savedModels, + thinkingErrors, + render, + unmount: async () => { + await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + }, + }; +} + +function Harness({ + options, + capture, +}: { + options: SessionModelSettingsIntentOptions; + capture(controller: SessionModelSettingsIntentController): void; +}) { + const controller = useSessionModelSettingsIntent(options); + capture(controller); + return createElement('output', { + 'data-model': controller.overlayBySession['session-a']?.model.model, + }); +} diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 3c74ddfd47..9ca8dd75dd 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -150,6 +150,7 @@ export function createAppShellChatActions(deps: { * window opens before any SessionEvent arrives (turn_started is not one). */ setLiveTurnBySession: LiveTurnRecordUpdater; setInteractionBySession: InteractionQueueUpdater; + settleSessionModelSettings(sessionId: string): Promise; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; @@ -196,6 +197,7 @@ export function createAppShellChatActions(deps: { setNavSelection, setLiveTurnBySession, setInteractionBySession, + settleSessionModelSettings, onInteractionChanged, onExecutionBoundaryChanged, showModelSetupToast, @@ -491,6 +493,10 @@ export function createAppShellChatActions(deps: { optimisticSessionId = sessionId; optimisticTurnId = turnId; armTurnActive(sessionId, turnId); + if (!(await settleSessionModelSettings(sessionId))) { + disarmTurnActive(sessionId, turnId); + return false; + } const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 47282012f6..588225d4f4 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -187,7 +187,6 @@ export function useAppShellBootstrapSubscriptions(options: { openHelp: () => void; openSettings: () => void; pendingPermissionModeChangesRef: RefBox>; - pendingSessionModelChangesRef: RefBox>; pendingTurnActionTimersRef: RefBox>>; pendingTurnActionsRef: RefBox>; projectPickerPendingRef: RefBox; @@ -321,7 +320,6 @@ export function useAppShellBootstrapSubscriptions(options: { options.pendingTurnActionTimersRef.current.clear(); options.pendingTurnActionsRef.current.clear(); options.pendingPermissionModeChangesRef.current.clear(); - options.pendingSessionModelChangesRef.current.clear(); }); useEffect(() => { diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 35b6def5f9..16026a8bad 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -19,7 +19,6 @@ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { PermissionMode } from '@maka/core/permission'; -import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { UiLocale } from '@maka/core/ui-locale'; import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; @@ -46,37 +45,27 @@ type ToastApi = { export interface AppShellSessionSettingsActions { setPermissionMode(mode: PermissionMode): Promise; - setSessionModel(input: { llmConnectionSlug: string; model: string }): Promise; - setSessionThinkingLevel(level: ThinkingLevel | undefined): Promise; } export function createAppShellSessionSettingsActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; pendingPermissionModeChangesRef: RefBox>; - pendingSessionModelChangesRef: RefBox>; refreshSessions: () => Promise; - saveComposerDefaults: (patch: { - model: { llmConnectionSlug: string; model: string }; - }) => void; sessionsRef: RefBox; /** Persists the chat default; awaited so a failure surfaces as one. */ setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void | Promise; setPendingPermissionModeBySession: BooleanRecordUpdater; - setPendingSessionModelBySession: BooleanRecordUpdater; toastApi: ToastApi; }): AppShellSessionSettingsActions { const { uiLocale, activeIdRef, pendingPermissionModeChangesRef, - pendingSessionModelChangesRef, refreshSessions, - saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, setPendingPermissionModeBySession, - setPendingSessionModelBySession, toastApi, } = deps; const copy = getShellCopy(uiLocale).sessionSettingsActions; @@ -144,66 +133,7 @@ export function createAppShellSessionSettingsActions(deps: { } } - async function setSessionModel(input: { llmConnectionSlug: string; model: string }) { - const sessionId = activeIdRef.current; - if (!sessionId) return; - if (pendingSessionModelChangesRef.current.has(sessionId)) return; - pendingSessionModelChangesRef.current.add(sessionId); - setPendingSessionModelBySession((current) => ({ - ...current, - [sessionId]: true, - })); - try { - await window.maka.sessions.setModel(sessionId, input); - saveComposerDefaults({ model: input }); - await refreshSessions(); - } catch (error) { - if (activeIdRef.current === sessionId) { - toastApi.error( - copy.modelFailedTitle, - localizedShellErrorMessage(error, copy.modelFallback, uiLocale), - undefined, - { sessionId }, - ); - } - } finally { - pendingSessionModelChangesRef.current.delete(sessionId); - setPendingSessionModelBySession((current) => omitSessionKey(current, sessionId)); - } - } - - async function setSessionThinkingLevel(level: ThinkingLevel | undefined) { - const sessionId = activeIdRef.current; - if (!sessionId) return; - const current = sessionsRef.current.find((session) => session.id === sessionId); - if (current && current.thinkingLevel === level) return; - if (pendingSessionModelChangesRef.current.has(sessionId)) return; - pendingSessionModelChangesRef.current.add(sessionId); - setPendingSessionModelBySession((currentPending) => ({ - ...currentPending, - [sessionId]: true, - })); - try { - await window.maka.sessions.setThinkingLevel(sessionId, level); - await refreshSessions(); - } catch (error) { - if (activeIdRef.current === sessionId) { - toastApi.error( - copy.thinkingFailedTitle, - localizedShellErrorMessage(error, copy.thinkingFallback, uiLocale), - undefined, - { sessionId }, - ); - } - } finally { - pendingSessionModelChangesRef.current.delete(sessionId); - setPendingSessionModelBySession((currentPending) => omitSessionKey(currentPending, sessionId)); - } - } - return { setPermissionMode, - setSessionModel, - setSessionThinkingLevel, }; } diff --git a/apps/desktop/src/renderer/app-shell-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts index bd3ba23947..27a066dc03 100644 --- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -34,7 +34,6 @@ export interface AppShellSessionUiState { interactionBySession: InteractionQueues; messageQueueBySession: Record; pendingPermissionModeBySession: Record; - pendingSessionModelBySession: Record; } // The pending plate keeps the Host revision beside its entries so edits can @@ -55,7 +54,6 @@ const SESSION_UI_MAP_KEYS = [ 'interactionBySession', 'messageQueueBySession', 'pendingPermissionModeBySession', - 'pendingSessionModelBySession', ] as const satisfies readonly AppShellSessionUiStateMapKey[]; type MissingSessionUiMapKey = Exclude; @@ -65,7 +63,7 @@ void allSessionUiMapsAreListed; // An authoritative session-list refresh heals a session whose turn ended while // its SessionEvent stream wasn't being followed, and must drop only the live // projection. The independently-scoped maps (message load error / retry, pending -// permission-mode / model toggles, the permission queue, stop-pending) each have +// permission-mode toggles, the permission queue, stop-pending) each have // their own lifecycle and must survive a mere turn settle — a full // `clearAppShellSessionUiStateForSession` (session deletion) would wipe them too. // Event-stream health is scoped the same way but lives outside this state; see @@ -185,7 +183,6 @@ export function createAppShellSessionUiStateController( sessionEventHealthBySessionRef.current = updater(sessionEventHealthBySessionRef.current); }) satisfies StateUpdater>, setPendingPermissionModeBySession: createMapSetter('pendingPermissionModeBySession'), - setPendingSessionModelBySession: createMapSetter('pendingSessionModelBySession'), /** * The authority said something about `turnId` — it started, failed to * start, or ended. Drop that arm's `unconfirmed` claim so a session list @@ -247,7 +244,6 @@ export function useAppShellSessionUiState() { setMessageQueueBySession: controller.setMessageQueueBySession, setSessionEventHealthBySession: controller.setSessionEventHealthBySession, setPendingPermissionModeBySession: controller.setPendingPermissionModeBySession, - setPendingSessionModelBySession: controller.setPendingSessionModelBySession, confirmLiveTurn: controller.confirmLiveTurn, clearSessionUiState: controller.clearSessionUiState, clearTurnTransientStateIfCurrent: controller.clearTurnTransientStateIfCurrent, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ffcd85d608..b44c2e6a05 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -131,6 +131,10 @@ import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { useShellSearch } from './use-shell-search'; import { useSessionSettingIntent } from './use-session-setting-intent'; +import { + projectSessionModelSettings, + useSessionModelSettingsIntent, +} from './use-session-model-settings-intent'; import { deriveStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; import { deriveProjectGroups, deriveWorktreeSessionIds } from './session-project-grouping'; @@ -376,7 +380,6 @@ function AppShellContent({ setMessageQueueBySession, setSessionEventHealthBySession, setPendingPermissionModeBySession, - setPendingSessionModelBySession, } = useAppShellSessionWorkspace(toastApi); const interactionHydrationEpochRef = useRef(new Map()); const markInteractionChanged = useCallback((sessionId: string) => { @@ -472,7 +475,6 @@ function AppShellContent({ interactionBySession, messageQueueBySession, pendingPermissionModeBySession, - pendingSessionModelBySession, streamingSessionIds, activeLiveTurnSnapshot, } = useAppShellSessionUiReads(sessionUiController, activeId); @@ -579,6 +581,7 @@ function AppShellContent({ setUiLocalePreference, }); const shellCopy = getShellCopy(uiLocale).app; + const sessionSettingsCopy = getShellCopy(uiLocale).sessionSettingsActions; const projectActionsCopy = getShellCopy(uiLocale).projectActions; const desktopConversationCopy = getDesktopConversationCopy(uiLocale); /** @@ -742,6 +745,38 @@ function AppShellContent({ const activeSession = sessions.find((session) => session.id === activeId); const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; const activeDesktopSession = activeSession; + const modelSettingsIntent = useSessionModelSettingsIntent({ + catalogRevision, + readAuthoritative: (sessionId) => + sessionsRef.current.find((session) => session.id === sessionId), + setModel: (sessionId, model) => window.maka.sessions.setModel(sessionId, model), + setThinkingLevel: (sessionId, level) => + window.maka.sessions.setThinkingLevel(sessionId, level), + refreshCatalog: refreshSessions, + saveComposerModel: (model) => saveComposerDefaults({ model }), + onModelError: (sessionId, error) => { + if (activeIdRef.current !== sessionId) return; + showSessionError( + sessionId, + sessionSettingsCopy.modelFailedTitle, + localizedShellErrorMessage(error, sessionSettingsCopy.modelFallback, uiLocale), + ); + }, + onThinkingError: (sessionId, error) => { + if (activeIdRef.current !== sessionId) return; + showSessionError( + sessionId, + sessionSettingsCopy.thinkingFailedTitle, + localizedShellErrorMessage(error, sessionSettingsCopy.thinkingFallback, uiLocale), + ); + }, + }); + const activeSessionForModelControls = activeSession + ? projectSessionModelSettings( + activeSession, + modelSettingsIntent.overlayBySession[activeSession.id], + ) + : undefined; // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the // semantic snapshot rather than the projection (#1985). @@ -811,7 +846,8 @@ function AppShellContent({ activationCandidate: modelSettingsOwnsComposerHost ? onboardingActivationCandidate : undefined, - activeSession, + activeSession: activeSessionForModelControls, + sessionHealthSession: activeSession, persistedComposerDefaults, usePersistedComposerDefaults: modelSettingsOwnsComposerHost, defaultThinkingLevel: newTask.selectedHost?.chatDefaults.thinkingLevel, @@ -826,11 +862,11 @@ function AppShellContent({ // mask. Per @kenji PR109d review: pending state prevents double-click // duplicate sibling turns by disabling the action button between // click and `sessions:changed turn-status-change` arriving. - // The four de-dup registries (turn-footer actions, session-row actions, - // per-session permission-mode / model changes) all share the same keyed-Set + // The three de-dup registries (turn-footer actions, session-row actions, + // and per-session permission-mode changes) share the same keyed-Set // shape; see useKeyedPendingRegistry. Only the turn-footer registry mirrors // into React state (drives the disabled mask) and arms a 5s auto-clear - // fallback timer; the other three stay ref-only and clear in their action's + // fallback timer; the other two stay ref-only and clear in their action's // `finally`. const turnActionRegistry = useKeyedPendingRegistry({ trackState: true, @@ -839,7 +875,6 @@ function AppShellContent({ const pendingTurnActions = turnActionRegistry.keys; const sessionRowActionRegistry = useKeyedPendingRegistry(); const permissionModeChangeRegistry = useKeyedPendingRegistry(); - const sessionModelChangeRegistry = useKeyedPendingRegistry(); const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) => `${sessionId}:${turnId}:${actionId}`; function omitSessionKey(current: Record, sessionId: string): Record { @@ -876,7 +911,7 @@ function AppShellContent({ permissionModeChangeRegistry.keysRef.current.delete(sessionId); planModeIntent.clear(sessionId); orchestrationModeIntent.clear(sessionId); - sessionModelChangeRegistry.keysRef.current.delete(sessionId); + modelSettingsIntent.clear(sessionId); } const sessionRowActionHandlers = useStableActions(createAppShellSessionRowActions, { @@ -901,21 +936,14 @@ function AppShellContent({ [], ); - const { - setPermissionMode, - setSessionModel, - setSessionThinkingLevel, - } = useStableActions(createAppShellSessionSettingsActions, { + const { setPermissionMode } = useStableActions(createAppShellSessionSettingsActions, { uiLocale, activeIdRef, pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, - pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, refreshSessions, - saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, setPendingPermissionModeBySession, - setPendingSessionModelBySession, toastApi, }); @@ -1759,6 +1787,7 @@ function AppShellContent({ setNavSelection, setLiveTurnBySession, setInteractionBySession, + settleSessionModelSettings: modelSettingsIntent.settle, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, showModelSetupToast, @@ -2237,7 +2266,6 @@ function AppShellContent({ openHelp, openSettings, pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, - pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, pendingTurnActionTimersRef: turnActionRegistry.timersRef, pendingTurnActionsRef: turnActionRegistry.keysRef, projectPickerPendingRef, @@ -2916,17 +2944,23 @@ function AppShellContent({ } modelLabel={activeModelLabel ?? newChatModelLabel ?? undefined} activeSession={activeSessionForView} + activeModelConnectionSlug={ + activeSessionForModelControls?.llmConnectionSlug + } activeModel={activeModel} activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} modelChoices={chatModelChoices} modelSwitchHasHistory={modelSwitchHasHistory} renderProviderMark={(type) => } - modelChangePending={activeId ? pendingSessionModelBySession[activeId] === true : false} - onModelChange={(input) => setSessionModel(input)} + onModelChange={(input) => { + if (activeId) modelSettingsIntent.selectModel(activeId, input); + }} activeThinkingLevels={activeThinkingLevels} activeThinkingLevel={activeThinkingLevel} - onThinkingLevelChange={(level) => setSessionThinkingLevel(level)} + onThinkingLevelChange={(level) => { + if (activeId) modelSettingsIntent.selectThinkingLevel(activeId, level); + }} newChatModel={newChatModel} newChatProviderType={newChatProviderType} onPickNewChatModel={(input) => { @@ -3025,8 +3059,9 @@ function AppShellContent({ activeProviderType={activeConnection?.providerType} renderProviderMark={(type) => } modelChoices={chatModelChoices} - modelChangePending={activeId ? pendingSessionModelBySession[activeId] === true : false} - onModelChange={(input) => setSessionModel(input)} + onModelChange={(input) => { + if (activeId) modelSettingsIntent.selectModel(activeId, input); + }} userLabel={userLabel} memoryActive={memoryActive} onOpenMemorySettings={() => openSettingsSection('memory')} diff --git a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts index aa2e24b123..416673b166 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts @@ -38,7 +38,6 @@ const selectStopPending = (state: AppShellSessionUiState) => state.stopPendingBy const selectInteraction = (state: AppShellSessionUiState) => state.interactionBySession; const selectMessageQueue = (state: AppShellSessionUiState) => state.messageQueueBySession; const selectPendingPermissionMode = (state: AppShellSessionUiState) => state.pendingPermissionModeBySession; -const selectPendingSessionModel = (state: AppShellSessionUiState) => state.pendingSessionModelBySession; const selectPulseSet = (state: AppShellSessionUiState) => selectStreamingSessionIds(state.liveTurnBySession); /** @@ -78,7 +77,6 @@ export function useAppShellSessionUiReads( interactionBySession: InteractionQueues; messageQueueBySession: Record; pendingPermissionModeBySession: Record; - pendingSessionModelBySession: Record; streamingSessionIds: Set; activeLiveTurnSnapshot: LiveTurnSnapshot; } { @@ -89,7 +87,6 @@ export function useAppShellSessionUiReads( interactionBySession: useAppShellSessionUiSelector(controller, selectInteraction), messageQueueBySession: useAppShellSessionUiSelector(controller, selectMessageQueue), pendingPermissionModeBySession: useAppShellSessionUiSelector(controller, selectPendingPermissionMode), - pendingSessionModelBySession: useAppShellSessionUiSelector(controller, selectPendingSessionModel), streamingSessionIds: useAppShellSessionUiSelector(controller, selectPulseSet, undefined, sessionIdSetsEqual), activeLiveTurnSnapshot: useAppShellSessionUiSelector( controller, diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index a43043eeda..25e3c74609 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -113,7 +113,6 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { setMessageQueueBySession: sessionUi.setMessageQueueBySession, setSessionEventHealthBySession: sessionUi.setSessionEventHealthBySession, setPendingPermissionModeBySession: sessionUi.setPendingPermissionModeBySession, - setPendingSessionModelBySession: sessionUi.setPendingSessionModelBySession, confirmLiveTurn: sessionUi.confirmLiveTurn, }; } diff --git a/apps/desktop/src/renderer/use-session-model-settings-intent.ts b/apps/desktop/src/renderer/use-session-model-settings-intent.ts new file mode 100644 index 0000000000..0340fb5b19 --- /dev/null +++ b/apps/desktop/src/renderer/use-session-model-settings-intent.ts @@ -0,0 +1,302 @@ +/* + * 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 type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { SessionSummary } from '@maka/core/session'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +export interface SessionModelTarget { + llmConnectionSlug: string; + model: string; +} + +export interface SessionModelSettings { + model: SessionModelTarget; + thinkingLevel: ThinkingLevel | undefined; +} + +export interface SessionModelSettingsIntentOptions { + catalogRevision: number; + readAuthoritative(sessionId: string): SessionSummary | undefined; + setModel(sessionId: string, model: SessionModelTarget): Promise; + setThinkingLevel( + sessionId: string, + level: ThinkingLevel | undefined, + ): Promise; + refreshCatalog(): Promise; + saveComposerModel(model: SessionModelTarget): void; + onModelError(sessionId: string, error: unknown): void; + onThinkingError(sessionId: string, error: unknown): void; +} + +export interface SessionModelSettingsIntentController { + overlayBySession: Readonly>; + selectModel(sessionId: string, model: SessionModelTarget): void; + selectThinkingLevel(sessionId: string, level: ThinkingLevel | undefined): void; + settle(sessionId: string): Promise; + clear(sessionId: string): void; +} + +interface ModelSettingsIntent { + desired: SessionModelSettings; + committed: SessionModelSettings; + committedAtCatalogRevision?: number; + inFlight?: Promise; +} + +function settingsFromSession(session: SessionSummary): SessionModelSettings { + return { + model: { + llmConnectionSlug: session.llmConnectionSlug, + model: session.model, + }, + thinkingLevel: session.thinkingLevel, + }; +} + +function sameModel(left: SessionModelTarget, right: SessionModelTarget): boolean { + return left.llmConnectionSlug === right.llmConnectionSlug && left.model === right.model; +} + +function sameSettings( + left: SessionModelSettings, + right: SessionModelSettings, +): boolean { + return sameModel(left.model, right.model) && left.thinkingLevel === right.thinkingLevel; +} + +export function projectSessionModelSettings( + session: T, + overlay: SessionModelSettings | undefined, +): T { + if (!overlay) return session; + return { + ...session, + llmConnectionSlug: overlay.model.llmConnectionSlug, + model: overlay.model.model, + thinkingLevel: overlay.thinkingLevel, + }; +} + +export function useSessionModelSettingsIntent( + options: SessionModelSettingsIntentOptions, +): SessionModelSettingsIntentController { + const optionsRef = useRef(options); + useLayoutEffect(() => { + optionsRef.current = options; + }); + const intentsRef = useRef(new Map()); + const [overlayBySession, setOverlayBySession] = useState< + Record + >({}); + + useEffect(() => () => { + intentsRef.current.clear(); + }, []); + + const setOverlay = useCallback((sessionId: string, value: SessionModelSettings | undefined) => { + setOverlayBySession((current) => { + if (value) return { ...current, [sessionId]: value }; + if (!(sessionId in current)) return current; + const next = { ...current }; + delete next[sessionId]; + return next; + }); + }, []); + + const reconcile = useCallback((sessionId: string): void => { + const intent = intentsRef.current.get(sessionId); + if ( + !intent || + intent.inFlight || + intent.committedAtCatalogRevision === undefined || + optionsRef.current.catalogRevision <= intent.committedAtCatalogRevision + ) { + return; + } + intentsRef.current.delete(sessionId); + setOverlay(sessionId, undefined); + }, [setOverlay]); + + useEffect(() => { + for (const sessionId of intentsRef.current.keys()) reconcile(sessionId); + }, [options.catalogRevision, reconcile]); + + const refreshCatalogInBackground = useCallback((): void => { + try { + void optionsRef.current.refreshCatalog().catch(() => undefined); + } catch { + // Refresh is a convergence nudge. The committed overlay remains until a + // later successful catalog revision can retire it. + } + }, []); + + const failLatest = useCallback(( + sessionId: string, + intent: ModelSettingsIntent, + field: 'model' | 'thinking', + error: unknown, + ): false => { + intent.desired = intent.committed; + if (field === 'model') optionsRef.current.onModelError(sessionId, error); + else optionsRef.current.onThinkingError(sessionId, error); + + if (intent.committedAtCatalogRevision === undefined) { + intentsRef.current.delete(sessionId); + setOverlay(sessionId, undefined); + } else { + setOverlay(sessionId, intent.committed); + refreshCatalogInBackground(); + } + return false; + }, [refreshCatalogInBackground, setOverlay]); + + const runWorker = useCallback(async ( + sessionId: string, + intent: ModelSettingsIntent, + ): Promise => { + while (intentsRef.current.get(sessionId) === intent) { + const before = intent.committed; + const desired = intent.desired; + if (!sameModel(before.model, desired.model)) { + const attempted = desired.model; + try { + const result = await optionsRef.current.setModel(sessionId, attempted); + if (intentsRef.current.get(sessionId) !== intent) return false; + intent.committed = settingsFromSession(result); + intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; + optionsRef.current.saveComposerModel(attempted); + } catch (error) { + if (intentsRef.current.get(sessionId) !== intent) return false; + if (sameModel(intent.desired.model, attempted)) { + return failLatest(sessionId, intent, 'model', error); + } + } + continue; + } + if (before.thinkingLevel !== desired.thinkingLevel) { + const attempted = desired.thinkingLevel; + const attemptedModel = desired.model; + try { + const result = await optionsRef.current.setThinkingLevel(sessionId, attempted); + if (intentsRef.current.get(sessionId) !== intent) return false; + intent.committed = settingsFromSession(result); + intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; + } catch (error) { + if (intentsRef.current.get(sessionId) !== intent) return false; + if ( + sameModel(intent.desired.model, attemptedModel) && + intent.desired.thinkingLevel === attempted + ) { + return failLatest(sessionId, intent, 'thinking', error); + } + } + continue; + } + break; + } + + if (intentsRef.current.get(sessionId) !== intent) return false; + if (intent.committedAtCatalogRevision === undefined) { + intentsRef.current.delete(sessionId); + setOverlay(sessionId, undefined); + } else { + setOverlay(sessionId, intent.committed); + refreshCatalogInBackground(); + } + return true; + }, [failLatest, refreshCatalogInBackground, setOverlay]); + + const startWorker = useCallback((sessionId: string, intent: ModelSettingsIntent): void => { + if (intent.inFlight) return; + const launch = (): Promise => { + let worker: Promise; + worker = runWorker(sessionId, intent).then( + (result) => { + if ( + intentsRef.current.get(sessionId) !== intent || + intent.inFlight !== worker + ) { + return result; + } + intent.inFlight = undefined; + if (!sameSettings(intent.committed, intent.desired)) { + const continuation = launch(); + intent.inFlight = continuation; + return continuation; + } + reconcile(sessionId); + return result; + }, + (error: unknown) => { + if ( + intentsRef.current.get(sessionId) === intent && + intent.inFlight === worker + ) { + intent.inFlight = undefined; + reconcile(sessionId); + } + throw error; + }, + ); + return worker; + }; + intent.inFlight = launch(); + }, [reconcile, runWorker]); + + const getOrCreateIntent = useCallback((sessionId: string): ModelSettingsIntent | undefined => { + const existing = intentsRef.current.get(sessionId); + if (existing) return existing; + const authoritative = optionsRef.current.readAuthoritative(sessionId); + if (!authoritative) return undefined; + const committed = settingsFromSession(authoritative); + const intent = { committed, desired: committed }; + intentsRef.current.set(sessionId, intent); + return intent; + }, []); + + const selectModel = useCallback((sessionId: string, model: SessionModelTarget): void => { + const intent = getOrCreateIntent(sessionId); + if (!intent) return; + intent.desired = { model, thinkingLevel: undefined }; + setOverlay(sessionId, intent.desired); + startWorker(sessionId, intent); + }, [getOrCreateIntent, setOverlay, startWorker]); + + const selectThinkingLevel = useCallback(( + sessionId: string, + thinkingLevel: ThinkingLevel | undefined, + ): void => { + const intent = getOrCreateIntent(sessionId); + if (!intent) return; + intent.desired = { ...intent.desired, thinkingLevel }; + setOverlay(sessionId, intent.desired); + startWorker(sessionId, intent); + }, [getOrCreateIntent, setOverlay, startWorker]); + + const settle = useCallback(async (sessionId: string): Promise => { + return intentsRef.current.get(sessionId)?.inFlight ?? true; + }, []); + const clear = useCallback((sessionId: string): void => { + intentsRef.current.delete(sessionId); + setOverlay(sessionId, undefined); + }, [setOverlay]); + + return { overlayBySession, selectModel, selectThinkingLevel, settle, clear }; +} diff --git a/apps/desktop/src/renderer/use-shell-chat-model.ts b/apps/desktop/src/renderer/use-shell-chat-model.ts index dc5bdcd8d7..d6c4e5430c 100644 --- a/apps/desktop/src/renderer/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/use-shell-chat-model.ts @@ -65,6 +65,7 @@ export function useShellChatModel(options: { newTaskKey: string; activationCandidate?: NewChatModel; activeSession: SessionSummary | undefined; + sessionHealthSession: SessionSummary | undefined; persistedComposerDefaults: ComposerDefaults | null; usePersistedComposerDefaults: boolean; /** Settings → 通用 → 默认思考级别; undefined means "no preference". */ @@ -88,7 +89,16 @@ export function useShellChatModel(options: { setPendingNewChatThinkingLevel: (next: ThinkingLevel | null) => void; sessionHealthNotice: SessionHealthNoticeView | undefined; } { - const { uiLocale, connections, defaultConnection, activationCandidate, activeSession, persistedComposerDefaults, openSettingsSection } = options; + const { + uiLocale, + connections, + defaultConnection, + activationCandidate, + activeSession, + sessionHealthSession, + persistedComposerDefaults, + openSettingsSection, + } = options; const conversationCopy = getDesktopConversationCopy(uiLocale); const [pendingNewChatModelChoice, setPendingNewChatModel] = useNewTaskChoice< NewChatModel | null @@ -103,6 +113,11 @@ export function useShellChatModel(options: { const activeConnection = activeSession ? connections.find((connection) => connection.slug === activeSession.llmConnectionSlug) : undefined; + const sessionHealthConnection = sessionHealthSession + ? connections.find( + (connection) => connection.slug === sessionHealthSession.llmConnectionSlug, + ) + : undefined; const { chatModelChoices } = options; // Home / empty-state composer: which model the next NEW chat starts with. // An explicit pick stays sticky; otherwise onboarding's readiness-checked @@ -192,10 +207,10 @@ export function useShellChatModel(options: { const sessionHealthNotice = useMemo(() => { const derived = deriveSessionHealthNotice({ locale: uiLocale, - session: activeSession, + session: sessionHealthSession, outcome: options.sessionSendOutcome, connections, - lastTestStatus: activeConnection?.lastTestStatus, + lastTestStatus: sessionHealthConnection?.lastTestStatus, }); if (!derived) return undefined; const target = derived.onClickTarget; @@ -211,12 +226,12 @@ export function useShellChatModel(options: { // effect to re-create on every render due to its function identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - activeSession?.id, - activeSession?.llmConnectionSlug, - activeSession?.model, + sessionHealthSession?.id, + sessionHealthSession?.llmConnectionSlug, + sessionHealthSession?.model, options.sessionSendOutcome, connections, - activeConnection?.lastTestStatus, + sessionHealthConnection?.lastTestStatus, uiLocale, ]); diff --git a/packages/ui/src/__tests__/chat-model-switcher.test.tsx b/packages/ui/src/__tests__/chat-model-switcher.test.tsx new file mode 100644 index 0000000000..d8e99598d0 --- /dev/null +++ b/packages/ui/src/__tests__/chat-model-switcher.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import type { ChatModelChoice } from '@maka/core/chat-model-choice'; +import type { SessionSummary } from '@maka/core/session'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { ChatModelSwitcher } from '../chat-model-switcher.js'; +import { LocaleProvider } from '../locale-context.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let root: Root | undefined; + +afterEach(async () => { + if (root) await act(() => root?.unmount()); + root = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test('can revert an optimistic cross-connection switch between same-named models', async () => { + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => ({ + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + root = createRoot(container); + const changes: Array<{ llmConnectionSlug: string; model: string }> = []; + + await act(() => { + root?.render( + + { + changes.push(target); + }} + /> + , + ); + }); + + const revertRow = [...document.querySelectorAll('[role="menuitem"]')] + .find((row) => row.textContent?.includes('Connection A model')); + assert.ok(revertRow, 'missing Connection A model option'); + await act(() => { + (revertRow as HTMLElement).click(); + }); + + assert.deepEqual(changes, [{ + llmConnectionSlug: 'connection-a', + model: 'shared-model', + }]); +}); + +const choices: ChatModelChoice[] = [ + { + connectionSlug: 'connection-a', + providerType: 'openai', + providerLabel: 'OpenAI', + connectionName: 'Connection A', + model: 'shared-model', + label: 'Connection A model', + isDefault: true, + thinkingLevels: [], + }, + { + connectionSlug: 'connection-b', + providerType: 'openai', + providerLabel: 'OpenAI', + connectionName: 'Connection B', + model: 'shared-model', + label: 'Connection B model', + isDefault: true, + thinkingLevels: [], + }, +]; + +function session(): SessionSummary { + return { + id: 'session-a', + name: 'Session A', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'connection-a', + connectionLocked: true, + model: 'shared-model', + permissionMode: 'ask', + }; +} diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx index e102258695..694348755c 100644 --- a/packages/ui/src/chat-model-switcher.tsx +++ b/packages/ui/src/chat-model-switcher.tsx @@ -139,7 +139,6 @@ export function ThinkingLevelSelector(props: { disabled?: boolean; /** Why the control is locked (mid-turn etc.); replaces the action tooltip so the reason is discoverable, matching the model switcher beside it. */ disabledReason?: string; - loading?: boolean; }) { const copy = getConversationCopy(useUiLocale()).model; const hasVariants = props.levels.length > 0 && Boolean(props.onChange); @@ -166,7 +165,6 @@ export function ThinkingLevelSelector(props: { variant: 'ghost', size: 'sm', isDisabled: props.disabled, - isLoading: props.loading, tooltip: props.disabledReason ?? copy.changeThinkingLevel, className: 'maka-thinking-level-selector', 'aria-label': `${copy.thinkingLevel}: ${currentLabel}`, @@ -191,29 +189,28 @@ export function ThinkingLevelSelector(props: { export function ChatModelSwitcher(props: { activeSession: SessionSummary; + activeModelConnectionSlug?: string; activeModel?: string; activeModelLabel?: string; currentProviderType?: ProviderType; choices: ChatModelChoice[]; hasConversationHistory?: boolean; - pending?: boolean; disabledReason?: string; renderProviderMark?(type: ProviderType): ReactNode; onChange?(input: { llmConnectionSlug: string; model: string }): void | Promise; }) { const locale = useUiLocale(); const copy = getConversationCopy(locale).model; + const currentConnectionSlug = + props.activeModelConnectionSlug ?? props.activeSession.llmConnectionSlug; const currentModel = props.activeModel ?? props.activeSession.model; - const currentValue = modelChoiceValue(props.activeSession.llmConnectionSlug, currentModel); - const pending = Boolean(props.pending); + const currentValue = modelChoiceValue(currentConnectionSlug, currentModel); const [menuOpen, setMenuOpen] = useState(false); - const disabled = pending || Boolean(props.disabledReason) || !props.onChange || props.choices.length === 0; + const disabled = Boolean(props.disabledReason) || !props.onChange || props.choices.length === 0; const grouped = modelMenuGroups(props.choices, locale); const currentKnownChoice = props.choices.some((choice) => modelChoiceValue(choice.connectionSlug, choice.model) === currentValue); const displayLabel = props.activeModelLabel ?? currentModel; - const title = pending - ? `${copy.switching}…` - : props.disabledReason ?? copy.switchAriaLabel; + const title = props.disabledReason ?? copy.switchAriaLabel; const announceWarning = menuOpen && props.hasConversationHistory === true; return ( @@ -229,7 +226,6 @@ export function ChatModelSwitcher(props: { variant: 'ghost', size: 'sm', isDisabled: disabled, - isLoading: pending, tooltip: title, className: 'maka-model-switcher-trigger', 'aria-label': copy.switchAriaLabel, @@ -249,7 +245,7 @@ export function ChatModelSwitcher(props: { disabled={disabled} onPick={async (next) => { if ( - next.llmConnectionSlug === props.activeSession.llmConnectionSlug && + next.llmConnectionSlug === currentConnectionSlug && next.model === currentModel ) return; try { diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index c05d77fd26..d3a424d513 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -93,7 +93,6 @@ export function ChatView(props: { * avoid bringing the full provider SVG library into @maka/ui. */ renderProviderMark?(type: ProviderType): ReactNode; modelChoices?: ChatModelChoice[]; - modelChangePending?: boolean; onModelChange?(input: { llmConnectionSlug: string; model: string }): void | Promise; /** Personalized user label shown on user messages. Falls back to "你". */ userLabel?: string; diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 49a09e979c..26402a5ab0 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -304,6 +304,7 @@ export const Composer = forwardRef< onPasteAsQuote?(input: { text: string; label?: string }): void; modelLabel?: string; activeSession?: SessionSummary; + activeModelConnectionSlug?: string; activeModel?: string; activeModelLabel?: string; activeProviderType?: ProviderType; @@ -313,7 +314,6 @@ export const Composer = forwardRef< /** Renders the provider brand mark beside each model option; * injected by the desktop app to keep the provider SVG library out of @maka/ui. */ renderProviderMark?(type: ProviderType): ReactNode; - modelChangePending?: boolean; onModelChange?(input: { llmConnectionSlug: string; model: string }): void | Promise; /** Per-model thinking-level variants for the active model; empty/undefined hides the switcher. */ activeThinkingLevels?: readonly import('@maka/core/model-thinking').ThinkingLevel[]; @@ -1917,12 +1917,12 @@ export const Composer = forwardRef< {props.activeSession ? ( ) : ( ; - switching: string; model: string; switchAriaLabel: string; switchWarning: string; @@ -466,7 +465,7 @@ const CONVERSATION_COPY = { // Canonical per-chat ladder: 默认 (model default, overriding Settings) / 关 / 低 / 中 / 高 / 超高 // (minimal/max when offered). level: { off: '关', minimal: '最少', low: '低', medium: '中', high: '高', xhigh: '超高', max: '最高' }, - switching: '切换中', model: '模型', switchAriaLabel: '切换当前任务模型', + model: '模型', switchAriaLabel: '切换当前任务模型', switchWarning: '切换模型可能需要重建服务商提示缓存,使下一次请求更慢或成本更高。', newChatAriaLabel: (label) => `选择新任务模型,当前 ${label}`, newChatTitle: (label) => `新任务使用的模型:${label}`, configureAriaLabel: (label) => `配置模型连接,当前 ${label}`, configureTitle: '配置模型连接', @@ -614,7 +613,7 @@ const CONVERSATION_COPY = { model: { thinkingLevel: 'Thinking level', thinkingUnsupported: 'This model does not support thinking-level changes', changeThinkingLevel: 'Change the current model thinking level', defaultLevel: 'Model default', level: { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Maximum' }, - switching: 'Switching', model: 'Model', switchAriaLabel: 'Switch model for this task', + model: 'Model', switchAriaLabel: 'Switch model for this task', switchWarning: 'Switching may rebuild the provider prompt cache, making the next request slower or more expensive.', newChatAriaLabel: (label) => `Choose a model for the new task, currently ${label}`, newChatTitle: (label) => `Model for the new task: ${label}`, configureAriaLabel: (label) => `Configure model connections, currently ${label}`, configureTitle: 'Configure model connections', From ee34dd00269372fb8f3d37bf222f34c1bddba6bc Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:24:26 +0800 Subject: [PATCH 03/24] fix(desktop): settle model settings across send paths Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 45 +++++++ .../app-shell-first-send-cleanup.test.ts | 1 + .../__tests__/app-shell-stop-action.test.ts | 111 ++++++++++++++++++ .../follow-up-submit-routing.test.ts | 44 +++++++ .../model-settings-visual-contract.test.ts | 1 + .../src/renderer/app-shell-chat-actions.ts | 29 ++++- .../src/renderer/app-shell-stop-action.ts | 6 +- apps/desktop/src/renderer/app-shell.tsx | 15 ++- .../src/renderer/follow-up-submit-routing.ts | 9 ++ 9 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 9d9a5d59c9..ba9236f05f 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -112,6 +112,7 @@ function createActionsDeps() { setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, + preSendSettingsRef: { current: new Map() }, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, @@ -173,6 +174,50 @@ describe('busy-raced send settlement', () => { } }); + it('cancels a pre-send settings wait before it reaches Runtime Host', async () => { + const settlement = deferred(); + let sessionSendCalls = 0; + const turnState = createTurnState(); + const restoreWindow = installWindow({ + sessions: { + send: async () => { + sessionSendCalls += 1; + return { + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + setLiveTurnBySession: turnState.setLiveTurnBySession, + settleSessionModelSettings: () => settlement.promise, + }); + const cancellable = actions as typeof actions & { + cancelPendingSend(sessionId: string): boolean; + }; + + const sending = actions.send('hello'); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(turnState.liveTurnBySession['session-a']?.phase, 'waiting'); + assert.equal(typeof cancellable.cancelPendingSend, 'function'); + assert.equal(cancellable.cancelPendingSend('session-a'), true); + + settlement.resolve(true); + assert.equal(await sending, false); + assert.equal(sessionSendCalls, 0); + assert.equal(turnState.liveTurnBySession['session-a'], undefined); + } finally { + restoreWindow(); + } + }); + it('disarms processing and skips send when model settings fail to settle', async () => { let sessionSendCalls = 0; const turnState = createTurnState(); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 21080564e3..8b0270855d 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -105,6 +105,7 @@ function createActionsDeps() { setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, + preSendSettingsRef: { current: new Map() }, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, diff --git a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts new file mode 100644 index 0000000000..85ae177f99 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { createAppShellStopAction } from '../../renderer/app-shell-stop-action.js'; + +function installWindow(maka: unknown): () => void { + const target = globalThis as unknown as { window?: unknown }; + const hadWindow = Object.prototype.hasOwnProperty.call(target, 'window'); + const previousWindow = target.window; + Object.defineProperty(target, 'window', { + configurable: true, + value: { maka }, + writable: true, + }); + return () => { + if (hadWindow) { + Object.defineProperty(target, 'window', { + configurable: true, + value: previousWindow, + writable: true, + }); + } else { + delete target.window; + } + }; +} + +describe('app shell stop action', () => { + it('cancels a local pre-send flight without stopping a nonexistent Host turn', async () => { + let localCancellations = 0; + let hostStops = 0; + const restoreWindow = installWindow({ + sessions: { + stop: async () => { + hostStops += 1; + }, + }, + }); + try { + const deps = { + uiLocale: 'en' as const, + activeIdRef: { current: 'session-a' as string | undefined }, + addPendingSessionAction: () => true, + clearPendingSessionAction: () => undefined, + setStopPendingBySession: () => undefined, + stopPendingRef: { current: new Set() }, + toastApi: { error: () => undefined }, + cancelPendingSend: (sessionId: string) => { + assert.equal(sessionId, 'session-a'); + localCancellations += 1; + return true; + }, + }; + + await createAppShellStopAction(deps)(); + + assert.equal(localCancellations, 1); + assert.equal(hostStops, 0); + } finally { + restoreWindow(); + } + }); + + it('stops the Runtime Host turn when no local pre-send flight exists', async () => { + let hostStops = 0; + const restoreWindow = installWindow({ + sessions: { + stop: async (sessionId: string, input: unknown) => { + assert.equal(sessionId, 'session-a'); + assert.deepEqual(input, { source: 'stop_button' }); + hostStops += 1; + }, + }, + }); + try { + await createAppShellStopAction({ + uiLocale: 'en', + activeIdRef: { current: 'session-a' }, + addPendingSessionAction: () => true, + clearPendingSessionAction: () => undefined, + setStopPendingBySession: () => undefined, + stopPendingRef: { current: new Set() }, + cancelPendingSend: () => false, + toastApi: { error: () => undefined }, + })(); + + assert.equal(hostStops, 1); + } finally { + restoreWindow(); + } + }); +}); diff --git a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts index b829b09021..6449ff7ab1 100644 --- a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts +++ b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts @@ -23,8 +23,17 @@ import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, resolveFollowUpModeAtSubmit, + submitFollowUpAfterModelSettings, } from '../../renderer/follow-up-submit-routing.js'; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + describe('follow-up submit routing', () => { it('uses the synchronous turn arm before React publishes streaming state', () => { assert.equal( @@ -71,6 +80,41 @@ describe('follow-up submit routing', () => { ); }); + it('waits for model settings before enqueueing a follow-up', async () => { + const settlement = deferred(); + let enqueueCalls = 0; + const submitting = submitFollowUpAfterModelSettings({ + sessionId: 'session-a', + settleSessionModelSettings: () => settlement.promise, + enqueue: async () => { + enqueueCalls += 1; + return true; + }, + }); + + await Promise.resolve(); + assert.equal(enqueueCalls, 0); + settlement.resolve(true); + assert.equal(await submitting, true); + assert.equal(enqueueCalls, 1); + }); + + it('does not enqueue a follow-up when model settings fail to settle', async () => { + let enqueueCalls = 0; + assert.equal( + await submitFollowUpAfterModelSettings({ + sessionId: 'session-a', + settleSessionModelSettings: async () => false, + enqueue: async () => { + enqueueCalls += 1; + return true; + }, + }), + false, + ); + assert.equal(enqueueCalls, 0); + }); + it('restores workspace references after queued text returns to the draft', () => { assert.deepEqual( mergeWorkspaceReferences( diff --git a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts index e74ae4175d..7a49479e1b 100644 --- a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts @@ -67,6 +67,7 @@ test('AppShell projects optimistic settings and gates send on their settlement', appShellSource, /settleSessionModelSettings:\s*modelSettingsIntent\.settle/, ); + assert.match(appShellSource, /submitFollowUpAfterModelSettings\(\{/); assert.match(appShellSource, /modelSettingsIntent\.selectModel\(activeId, input\)/); assert.match( appShellSource, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 9ca8dd75dd..b958959f13 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -112,6 +112,7 @@ export interface AppShellChatActions { onSessionResolved?: (sessionId: string) => void; }, ): Promise; + cancelPendingSend(sessionId: string): boolean; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; @@ -150,6 +151,7 @@ export function createAppShellChatActions(deps: { * window opens before any SessionEvent arrives (turn_started is not one). */ setLiveTurnBySession: LiveTurnRecordUpdater; setInteractionBySession: InteractionQueueUpdater; + preSendSettingsRef: RefBox>; settleSessionModelSettings(sessionId: string): Promise; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ @@ -197,6 +199,7 @@ export function createAppShellChatActions(deps: { setNavSelection, setLiveTurnBySession, setInteractionBySession, + preSendSettingsRef, settleSessionModelSettings, onInteractionChanged, onExecutionBoundaryChanged, @@ -298,6 +301,20 @@ export function createAppShellChatActions(deps: { }); } + function finishPendingSend(sessionId: string, turnId: string): boolean { + if (preSendSettingsRef.current.get(sessionId) !== turnId) return false; + preSendSettingsRef.current.delete(sessionId); + return true; + } + + function cancelPendingSend(sessionId: string): boolean { + const turnId = preSendSettingsRef.current.get(sessionId); + if (turnId === undefined) return false; + preSendSettingsRef.current.delete(sessionId); + disarmTurnActive(sessionId, turnId); + return true; + } + // Rename only the exact unconfirmed arm this send created. Host events can // beat the IPC response (main emits the sessions-changed nudge before it // returns), and an authoritative projection that already arrived for the @@ -492,8 +509,17 @@ export function createAppShellChatActions(deps: { } optimisticSessionId = sessionId; optimisticTurnId = turnId; + preSendSettingsRef.current.set(sessionId, turnId); armTurnActive(sessionId, turnId); - if (!(await settleSessionModelSettings(sessionId))) { + let settingsSettled: boolean; + try { + settingsSettled = await settleSessionModelSettings(sessionId); + } catch (error) { + finishPendingSend(sessionId, turnId); + throw error; + } + if (!finishPendingSend(sessionId, turnId)) return false; + if (!settingsSettled) { disarmTurnActive(sessionId, turnId); return false; } @@ -729,6 +755,7 @@ export function createAppShellChatActions(deps: { return { send, + cancelPendingSend, respondToSandboxBoundary, respondToUserQuestion, refreshMessages, diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index 81b8c34dc7..6e3e071ebb 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -48,6 +48,7 @@ export function createAppShellStopAction(deps: { ) => void; setStopPendingBySession: BooleanRecordUpdater; stopPendingRef: RefBox>; + cancelPendingSend(sessionId: string): boolean; toastApi: ToastApi; }): () => Promise { const { @@ -57,6 +58,7 @@ export function createAppShellStopAction(deps: { clearPendingSessionAction, setStopPendingBySession, stopPendingRef, + cancelPendingSend, toastApi, } = deps; @@ -64,7 +66,9 @@ export function createAppShellStopAction(deps: { const sessionId = activeIdRef.current; if (!sessionId || !addPendingSessionAction(sessionId, stopPendingRef, setStopPendingBySession)) return; try { - await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); + if (!cancelPendingSend(sessionId)) { + await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); + } } catch (error) { // The Composer wires this through both the Stop button onClick // and the Escape key. Both invoke `onStop` without awaiting, so diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index b44c2e6a05..b0869e4850 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -102,6 +102,7 @@ import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, resolveFollowUpModeAtSubmit, + submitFollowUpAfterModelSettings, } from './follow-up-submit-routing'; import { PlanExecutionPanel, @@ -1762,8 +1763,10 @@ function AppShellContent({ setUiLocaleOverride, }); + const preSendSettingsRef = useRef(new Map()); const { send, + cancelPendingSend, respondToSandboxBoundary, respondToUserQuestion, refreshMessages, @@ -1787,6 +1790,7 @@ function AppShellContent({ setNavSelection, setLiveTurnBySession, setInteractionBySession, + preSendSettingsRef, settleSessionModelSettings: modelSettingsIntent.settle, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -1944,9 +1948,13 @@ function AppShellContent({ }) : undefined; if (sessionId && followUpAtSubmit) { - const queued = await enqueueFollowUp(sessionId, text, followUpAtSubmit, { - ...metadata, - workspaceFileReferences, + const queued = await submitFollowUpAfterModelSettings({ + sessionId, + settleSessionModelSettings: modelSettingsIntent.settle, + enqueue: () => enqueueFollowUp(sessionId, text, followUpAtSubmit, { + ...metadata, + workspaceFileReferences, + }), }); if (queued) delete retractedWorkspaceReferencesRef.current[sessionId]; return queued; @@ -2194,6 +2202,7 @@ function AppShellContent({ clearPendingSessionAction, setStopPendingBySession, stopPendingRef, + cancelPendingSend, toastApi, }); diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 768477dba3..4c8b46dca2 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -42,6 +42,15 @@ export function resolveFollowUpModeAtSubmit(input: { return input.hasActiveTurn ? 'queue' : undefined; } +export async function submitFollowUpAfterModelSettings(input: { + sessionId: string; + settleSessionModelSettings(sessionId: string): Promise; + enqueue(): Promise; +}): Promise { + if (!(await input.settleSessionModelSettings(input.sessionId))) return false; + return input.enqueue(); +} + export function mergeWorkspaceReferences( text: string, live: readonly WorkspaceFileReferencePosition[] | undefined, From 046484bd7f4beba60d05cef948f828fd7d77e539 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:13:41 +0800 Subject: [PATCH 04/24] fix(desktop): close model-setting send races Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 42 +++++++ .../app-shell-first-send-cleanup.test.ts | 1 + .../__tests__/app-shell-stop-action.test.ts | 4 +- .../follow-up-submit-routing.test.ts | 45 ++++++++ .../model-settings-visual-contract.test.ts | 2 + .../session-model-settings-intent.test.ts | 104 ++++++++++++++++++ .../src/renderer/app-shell-chat-actions.ts | 8 +- .../src/renderer/app-shell-stop-action.ts | 5 +- apps/desktop/src/renderer/app-shell.tsx | 7 ++ .../src/renderer/follow-up-submit-routing.ts | 2 + .../use-session-model-settings-intent.ts | 41 ++++++- 11 files changed, 251 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index ba9236f05f..712327b98a 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -113,6 +113,7 @@ function createActionsDeps() { setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, preSendSettingsRef: { current: new Map() }, + sendCancellationEpochRef: { current: new Map() }, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, @@ -218,6 +219,47 @@ describe('busy-raced send settlement', () => { } }); + it('does not dispatch after the composer surface owner changes during settlement', async () => { + const settlement = deferred(); + let ownsSurface = true; + let sessionSendCalls = 0; + const turnState = createTurnState(); + const restoreWindow = installWindow({ + sessions: { + send: async () => { + sessionSendCalls += 1; + return { + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + isShellSurfaceOwnerActive: () => ownsSurface, + setLiveTurnBySession: turnState.setLiveTurnBySession, + settleSessionModelSettings: () => settlement.promise, + }); + + const sending = actions.send('hello'); + await Promise.resolve(); + await Promise.resolve(); + ownsSurface = false; + settlement.resolve(true); + + assert.equal(await sending, false); + assert.equal(sessionSendCalls, 0); + assert.equal(turnState.liveTurnBySession['session-a'], undefined); + } finally { + restoreWindow(); + } + }); + it('disarms processing and skips send when model settings fail to settle', async () => { let sessionSendCalls = 0; const turnState = createTurnState(); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 8b0270855d..b0ae9bb09b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -106,6 +106,7 @@ function createActionsDeps() { setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, preSendSettingsRef: { current: new Map() }, + sendCancellationEpochRef: { current: new Map() }, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, diff --git a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts index 85ae177f99..9360557562 100644 --- a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts @@ -45,7 +45,7 @@ function installWindow(maka: unknown): () => void { } describe('app shell stop action', () => { - it('cancels a local pre-send flight without stopping a nonexistent Host turn', async () => { + it('cancels a local pre-send flight and still forwards Stop to Runtime Host', async () => { let localCancellations = 0; let hostStops = 0; const restoreWindow = installWindow({ @@ -74,7 +74,7 @@ describe('app shell stop action', () => { await createAppShellStopAction(deps)(); assert.equal(localCancellations, 1); - assert.equal(hostStops, 0); + assert.equal(hostStops, 1); } finally { restoreWindow(); } diff --git a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts index 6449ff7ab1..7ab48acd26 100644 --- a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts +++ b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts @@ -86,6 +86,7 @@ describe('follow-up submit routing', () => { const submitting = submitFollowUpAfterModelSettings({ sessionId: 'session-a', settleSessionModelSettings: () => settlement.promise, + isSubmissionCurrent: () => true, enqueue: async () => { enqueueCalls += 1; return true; @@ -105,6 +106,7 @@ describe('follow-up submit routing', () => { await submitFollowUpAfterModelSettings({ sessionId: 'session-a', settleSessionModelSettings: async () => false, + isSubmissionCurrent: () => true, enqueue: async () => { enqueueCalls += 1; return true; @@ -115,6 +117,49 @@ describe('follow-up submit routing', () => { assert.equal(enqueueCalls, 0); }); + it('does not enqueue after Stop cancels the follow-up waiter', async () => { + const settlement = deferred(); + let cancellationEpoch = 0; + let enqueueCalls = 0; + const capturedEpoch = cancellationEpoch; + const submitting = submitFollowUpAfterModelSettings({ + sessionId: 'session-a', + settleSessionModelSettings: () => settlement.promise, + isSubmissionCurrent: () => cancellationEpoch === capturedEpoch, + enqueue: async () => { + enqueueCalls += 1; + return true; + }, + }); + + cancellationEpoch += 1; + settlement.resolve(true); + + assert.equal(await submitting, false); + assert.equal(enqueueCalls, 0); + }); + + it('does not enqueue after the composer surface owner changes', async () => { + const settlement = deferred(); + let ownsSurface = true; + let enqueueCalls = 0; + const submitting = submitFollowUpAfterModelSettings({ + sessionId: 'session-a', + settleSessionModelSettings: () => settlement.promise, + isSubmissionCurrent: () => ownsSurface, + enqueue: async () => { + enqueueCalls += 1; + return true; + }, + }); + + ownsSurface = false; + settlement.resolve(true); + + assert.equal(await submitting, false); + assert.equal(enqueueCalls, 0); + }); + it('restores workspace references after queued text returns to the draft', () => { assert.deepEqual( mergeWorkspaceReferences( diff --git a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts index 7a49479e1b..7c654f6d2c 100644 --- a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts @@ -68,6 +68,8 @@ test('AppShell projects optimistic settings and gates send on their settlement', /settleSessionModelSettings:\s*modelSettingsIntent\.settle/, ); assert.match(appShellSource, /submitFollowUpAfterModelSettings\(\{/); + assert.match(appShellSource, /isSubmissionCurrent:\s*\(\) =>/); + assert.match(appShellSource, /sendCancellationEpochRef\.current\.get\(sessionId\)/); assert.match(appShellSource, /modelSettingsIntent\.selectModel\(activeId, input\)/); assert.match( appShellSource, diff --git a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts index 978647df61..5cb869ff3c 100644 --- a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts +++ b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts @@ -391,6 +391,110 @@ test('does not apply an old-model thinking failure to the same level on a newer }); }); +test('rebases a pending thinking intent onto a model committed by another client', async () => { + const firstThinkingWrite = deferred(); + const thinkingWrites: Array = []; + const harness = await mountIntent({ + setModel: async (_sessionId, model) => session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }), + setThinkingLevel: async (_sessionId, level) => { + thinkingWrites.push(level); + if (thinkingWrites.length === 1) return firstThinkingWrite.promise; + return session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.5', + thinkingLevel: level, + }); + }, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + harness.controller().selectThinkingLevel('session-a', 'high'); + await Promise.resolve(); + }); + assert.deepEqual(thinkingWrites, ['high']); + + await harness.render(1, session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.5', + thinkingLevel: undefined, + })); + assert.equal( + harness.controller().overlayBySession['session-a']?.model.model, + 'gpt-5.5', + ); + + firstThinkingWrite.reject(new Error('old-model thinking rejected')); + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(thinkingWrites, ['high', 'high']); + assert.deepEqual(harness.thinkingErrors, []); + assert.deepEqual(harness.controller().overlayBySession['session-a'], { + model: { llmConnectionSlug: 'openai', model: 'gpt-5.5' }, + thinkingLevel: 'high', + }); +}); + +test('ignores a stale thinking success from the model replaced by another client', async () => { + const firstThinkingWrite = deferred(); + const thinkingWrites: Array = []; + const harness = await mountIntent({ + setModel: async (_sessionId, model) => session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }), + setThinkingLevel: async (_sessionId, level) => { + thinkingWrites.push(level); + if (thinkingWrites.length === 1) return firstThinkingWrite.promise; + return session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.5', + thinkingLevel: level, + }); + }, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + harness.controller().selectThinkingLevel('session-a', 'high'); + await Promise.resolve(); + }); + + await harness.render(1, session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.5', + thinkingLevel: undefined, + })); + firstThinkingWrite.resolve(session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + thinkingLevel: 'high', + })); + + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(thinkingWrites, ['high', 'high']); + assert.deepEqual(harness.controller().overlayBySession['session-a'], { + model: { llmConnectionSlug: 'openai', model: 'gpt-5.5' }, + thinkingLevel: 'high', + }); +}); + test('rolls back and reports a terminal first model failure', async () => { const modelWrite = deferred(); const harness = await mountIntent({ diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index b958959f13..61ed5ec24e 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -152,6 +152,7 @@ export function createAppShellChatActions(deps: { setLiveTurnBySession: LiveTurnRecordUpdater; setInteractionBySession: InteractionQueueUpdater; preSendSettingsRef: RefBox>; + sendCancellationEpochRef: RefBox>; settleSessionModelSettings(sessionId: string): Promise; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ @@ -200,6 +201,7 @@ export function createAppShellChatActions(deps: { setLiveTurnBySession, setInteractionBySession, preSendSettingsRef, + sendCancellationEpochRef, settleSessionModelSettings, onInteractionChanged, onExecutionBoundaryChanged, @@ -308,6 +310,10 @@ export function createAppShellChatActions(deps: { } function cancelPendingSend(sessionId: string): boolean { + sendCancellationEpochRef.current.set( + sessionId, + (sendCancellationEpochRef.current.get(sessionId) ?? 0) + 1, + ); const turnId = preSendSettingsRef.current.get(sessionId); if (turnId === undefined) return false; preSendSettingsRef.current.delete(sessionId); @@ -519,7 +525,7 @@ export function createAppShellChatActions(deps: { throw error; } if (!finishPendingSend(sessionId, turnId)) return false; - if (!settingsSettled) { + if (!settingsSettled || !isShellSurfaceOwnerActive(sendOwner)) { disarmTurnActive(sessionId, turnId); return false; } diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index 6e3e071ebb..c5b4c1cb89 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -66,9 +66,8 @@ export function createAppShellStopAction(deps: { const sessionId = activeIdRef.current; if (!sessionId || !addPendingSessionAction(sessionId, stopPendingRef, setStopPendingBySession)) return; try { - if (!cancelPendingSend(sessionId)) { - await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); - } + cancelPendingSend(sessionId); + await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); } catch (error) { // The Composer wires this through both the Stop button onClick // and the Escape key. Both invoke `onStop` without awaiting, so diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index b0869e4850..2f3e157e6c 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1764,6 +1764,7 @@ function AppShellContent({ }); const preSendSettingsRef = useRef(new Map()); + const sendCancellationEpochRef = useRef(new Map()); const { send, cancelPendingSend, @@ -1791,6 +1792,7 @@ function AppShellContent({ setLiveTurnBySession, setInteractionBySession, preSendSettingsRef, + sendCancellationEpochRef, settleSessionModelSettings: modelSettingsIntent.settle, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -1948,9 +1950,14 @@ function AppShellContent({ }) : undefined; if (sessionId && followUpAtSubmit) { + const followUpOwner = captureComposerImportOwner(); + const cancellationEpoch = sendCancellationEpochRef.current.get(sessionId) ?? 0; const queued = await submitFollowUpAfterModelSettings({ sessionId, settleSessionModelSettings: modelSettingsIntent.settle, + isSubmissionCurrent: () => + isShellSurfaceOwnerActive(followUpOwner) && + (sendCancellationEpochRef.current.get(sessionId) ?? 0) === cancellationEpoch, enqueue: () => enqueueFollowUp(sessionId, text, followUpAtSubmit, { ...metadata, workspaceFileReferences, diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 4c8b46dca2..25f8e82797 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -45,9 +45,11 @@ export function resolveFollowUpModeAtSubmit(input: { export async function submitFollowUpAfterModelSettings(input: { sessionId: string; settleSessionModelSettings(sessionId: string): Promise; + isSubmissionCurrent(): boolean; enqueue(): Promise; }): Promise { if (!(await input.settleSessionModelSettings(input.sessionId))) return false; + if (!input.isSubmissionCurrent()) return false; return input.enqueue(); } diff --git a/apps/desktop/src/renderer/use-session-model-settings-intent.ts b/apps/desktop/src/renderer/use-session-model-settings-intent.ts index 0340fb5b19..2ed1c4d2c8 100644 --- a/apps/desktop/src/renderer/use-session-model-settings-intent.ts +++ b/apps/desktop/src/renderer/use-session-model-settings-intent.ts @@ -81,6 +81,23 @@ function sameSettings( return sameModel(left.model, right.model) && left.thinkingLevel === right.thinkingLevel; } +function rebaseUncommittedFields( + intent: ModelSettingsIntent, + authoritative: SessionModelSettings, +): boolean { + const previousDesired = intent.desired; + const ownsModel = !sameModel(previousDesired.model, intent.committed.model); + const ownsThinking = previousDesired.thinkingLevel !== intent.committed.thinkingLevel; + intent.committed = authoritative; + intent.desired = { + model: ownsModel ? previousDesired.model : authoritative.model, + thinkingLevel: ownsThinking + ? previousDesired.thinkingLevel + : authoritative.thinkingLevel, + }; + return !sameSettings(previousDesired, intent.desired); +} + export function projectSessionModelSettings( session: T, overlay: SessionModelSettings | undefined, @@ -135,8 +152,18 @@ export function useSessionModelSettingsIntent( }, [setOverlay]); useEffect(() => { - for (const sessionId of intentsRef.current.keys()) reconcile(sessionId); - }, [options.catalogRevision, reconcile]); + for (const [sessionId, intent] of intentsRef.current) { + if (intent.inFlight) { + const authoritative = optionsRef.current.readAuthoritative(sessionId); + if (authoritative) { + const rebased = rebaseUncommittedFields(intent, settingsFromSession(authoritative)); + intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; + if (rebased) setOverlay(sessionId, intent.desired); + } + } + reconcile(sessionId); + } + }, [options.catalogRevision, reconcile, setOverlay]); const refreshCatalogInBackground = useCallback((): void => { try { @@ -196,8 +223,14 @@ export function useSessionModelSettingsIntent( try { const result = await optionsRef.current.setThinkingLevel(sessionId, attempted); if (intentsRef.current.get(sessionId) !== intent) return false; - intent.committed = settingsFromSession(result); - intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; + const committed = settingsFromSession(result); + if ( + sameModel(intent.desired.model, committed.model) && + intent.desired.thinkingLevel === committed.thinkingLevel + ) { + intent.committed = committed; + intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; + } } catch (error) { if (intentsRef.current.get(sessionId) !== intent) return false; if ( From a401931449c95ab3d1ab4c030b9119a38cbffc9b Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:55:20 +0800 Subject: [PATCH 05/24] fix(desktop): preserve latest model setting intent Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 59 ++++++++++ .../app-shell-first-send-cleanup.test.ts | 1 + .../model-settings-visual-contract.test.ts | 11 ++ .../session-model-settings-intent.test.ts | 104 ++++++++++++++++++ .../src/renderer/app-shell-chat-actions.ts | 20 +++- apps/desktop/src/renderer/app-shell.tsx | 20 +++- .../use-session-model-settings-intent.ts | 94 ++++++++++++---- .../renderer/use-task-submission-readiness.ts | 9 +- 8 files changed, 289 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 712327b98a..cc393c765c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -114,6 +114,7 @@ function createActionsDeps() { setInteractionBySession: () => undefined, preSendSettingsRef: { current: new Map() }, sendCancellationEpochRef: { current: new Map() }, + readSessionModelSettings: () => undefined, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, @@ -260,6 +261,64 @@ describe('busy-raced send settlement', () => { } }); + it('checks readiness against the settled model before dispatching', async () => { + const settlement = deferred(); + const calls: string[] = []; + let sessionSendCalls = 0; + const restoreWindow = installWindow({ + sessions: { + send: async () => { + sessionSendCalls += 1; + return { + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const deps = { + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + checkTaskSubmissionReadiness: async (model?: { + llmConnectionSlug: string; + model: string; + }) => { + calls.push(`readiness:${model?.model ?? 'missing'}`); + return true; + }, + readSessionModelSettings: () => ({ + model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, + thinkingLevel: 'high' as const, + }), + settleSessionModelSettings: async () => { + calls.push('settle:start'); + const settled = await settlement.promise; + calls.push('settle:end'); + return settled; + }, + }; + const actions = createAppShellChatActions(deps); + + const sending = actions.send('hello'); + await Promise.resolve(); + await Promise.resolve(); + settlement.resolve(true); + + assert.equal(await sending, true); + assert.deepEqual(calls, [ + 'settle:start', + 'settle:end', + 'readiness:gpt-5.6-sol', + ]); + assert.equal(sessionSendCalls, 1); + } finally { + restoreWindow(); + } + }); + it('disarms processing and skips send when model settings fail to settle', async () => { let sessionSendCalls = 0; const turnState = createTurnState(); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index b0ae9bb09b..8f809e0e7e 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -107,6 +107,7 @@ function createActionsDeps() { setInteractionBySession: () => undefined, preSendSettingsRef: { current: new Map() }, sendCancellationEpochRef: { current: new Map() }, + readSessionModelSettings: () => undefined, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, diff --git a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts index 7c654f6d2c..73edfc9b52 100644 --- a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts @@ -77,6 +77,17 @@ test('AppShell projects optimistic settings and gates send on their settlement', ); }); +test('model setting failures stay on the chat surface that owns the session', () => { + const intentOptions = appShellSource.slice( + appShellSource.indexOf('const modelSettingsIntent = useSessionModelSettingsIntent'), + appShellSource.indexOf('const activeSessionForModelControls'), + ); + const surfaceGuards = intentOptions.match( + /isComposerImportOwnerActive\(\{\s*sessionId,\s*navSection:\s*'sessions',?\s*\}\)/g, + ); + assert.equal(surfaceGuards?.length, 2); +}); + test('optimistic settings stay inside committed model-control state', () => { assert.match( modelSettingsIntentSource, diff --git a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts index 5cb869ff3c..1c19b4764e 100644 --- a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts +++ b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts @@ -109,6 +109,31 @@ test('coalesces rapid thinking changes to the latest pending level', async () => assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, 'low'); }); +test('writes the original thinking level after reverting an in-flight change', async () => { + const first = deferred(); + const writes: Array = []; + const harness = await mountIntent({ + setThinkingLevel: async (_sessionId, level) => { + writes.push(level); + if (writes.length === 1) return first.promise; + return session({ thinkingLevel: level }); + }, + }); + + await act(async () => { + harness.controller().selectThinkingLevel('session-a', 'high'); + harness.controller().selectThinkingLevel('session-a', undefined); + }); + first.resolve(session({ thinkingLevel: 'high' })); + + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(writes, ['high', undefined]); + assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, undefined); +}); + test('coalesces rapid model changes to the latest pending model', async () => { const first = deferred(); const writes: string[] = []; @@ -151,6 +176,53 @@ test('coalesces rapid model changes to the latest pending model', async () => { ); }); +test('preserves a reverted model when catalog refresh observes the in-flight change', async () => { + const first = deferred(); + const writes: string[] = []; + const harness = await mountIntent({ + setModel: async (_sessionId, model) => { + writes.push(model.model); + if (writes.length === 1) return first.promise; + return session({ + llmConnectionSlug: model.llmConnectionSlug, + model: model.model, + thinkingLevel: undefined, + }); + }, + }); + + await act(async () => { + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + }); + harness.controller().selectModel('session-a', { + llmConnectionSlug: 'anthropic', + model: 'claude-sonnet', + }); + }); + await harness.render(1, session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + thinkingLevel: undefined, + })); + first.resolve(session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.6-sol', + thinkingLevel: undefined, + })); + + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(writes, ['gpt-5.6-sol', 'claude-sonnet']); + assert.deepEqual(harness.controller().overlayBySession['session-a']?.model, { + llmConnectionSlug: 'anthropic', + model: 'claude-sonnet', + }); +}); + test('continues with a selection made while the current worker is settling', async () => { const writes: string[] = []; let refreshes = 0; @@ -495,6 +567,38 @@ test('ignores a stale thinking success from the model replaced by another client }); }); +test('adopts the authoritative model returned by a thinking mutation without retrying', async () => { + const thinkingWrites: Array = []; + const harness = await mountIntent({ + setThinkingLevel: async (_sessionId, level) => { + thinkingWrites.push(level); + if (thinkingWrites.length > 1) { + throw new Error('the authoritative response must settle the intent'); + } + return session({ + llmConnectionSlug: 'openai', + model: 'gpt-5.5', + thinkingLevel: level, + }); + }, + refreshCatalog: async () => { + throw new Error('catalog refresh unavailable'); + }, + }); + + await act(async () => { + harness.controller().selectThinkingLevel('session-a', 'high'); + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(thinkingWrites, ['high']); + assert.deepEqual(harness.thinkingErrors, []); + assert.deepEqual(harness.controller().overlayBySession['session-a'], { + model: { llmConnectionSlug: 'openai', model: 'gpt-5.5' }, + thinkingLevel: 'high', + }); +}); + test('rolls back and reports a terminal first model failure', async () => { const modelWrite = deferred(); const harness = await mountIntent({ diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 61ed5ec24e..587c66143e 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -88,6 +88,11 @@ type PendingNewChatModel = { model: string; } | null; +type SessionModelSettings = { + model: NonNullable; + thinkingLevel: ThinkingLevel | undefined; +}; + type PendingNewChatThinkingLevel = ThinkingLevel | null; type ToastApi = { @@ -128,7 +133,7 @@ export function createAppShellChatActions(deps: { setPendingBySession: BooleanRecordUpdater, ) => boolean; captureComposerImportOwner: () => ComposerImportOwner; - checkTaskSubmissionReadiness: () => Promise; + checkTaskSubmissionReadiness: (model?: NonNullable) => Promise; clearPendingSessionAction: ( sessionId: string, pendingRef: RefBox>, @@ -153,6 +158,7 @@ export function createAppShellChatActions(deps: { setInteractionBySession: InteractionQueueUpdater; preSendSettingsRef: RefBox>; sendCancellationEpochRef: RefBox>; + readSessionModelSettings(sessionId: string): SessionModelSettings | undefined; settleSessionModelSettings(sessionId: string): Promise; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ @@ -202,6 +208,7 @@ export function createAppShellChatActions(deps: { setInteractionBySession, preSendSettingsRef, sendCancellationEpochRef, + readSessionModelSettings, settleSessionModelSettings, onInteractionChanged, onExecutionBoundaryChanged, @@ -372,7 +379,7 @@ export function createAppShellChatActions(deps: { const sendOwner = captureComposerImportOwner(); const newChatOwner = initialSessionId ? null : sendOwner; if (!initialSessionId && !initialNewTaskTarget) return false; - if (!(await checkTaskSubmissionReadiness())) return false; + if (!initialSessionId && !(await checkTaskSubmissionReadiness())) return false; if ( (initialSessionId && !isShellSurfaceOwnerActive(sendOwner)) || (newChatOwner && !isNewChatSendSurfaceActive(newChatOwner)) @@ -524,8 +531,15 @@ export function createAppShellChatActions(deps: { finishPendingSend(sessionId, turnId); throw error; } - if (!finishPendingSend(sessionId, turnId)) return false; if (!settingsSettled || !isShellSurfaceOwnerActive(sendOwner)) { + finishPendingSend(sessionId, turnId); + disarmTurnActive(sessionId, turnId); + return false; + } + const settledModel = readSessionModelSettings(sessionId)?.model; + const submissionReady = await checkTaskSubmissionReadiness(settledModel); + if (!finishPendingSend(sessionId, turnId)) return false; + if (!submissionReady || !isShellSurfaceOwnerActive(sendOwner)) { disarmTurnActive(sessionId, turnId); return false; } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 2f3e157e6c..3ad0624622 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -134,6 +134,7 @@ import { useShellSearch } from './use-shell-search'; import { useSessionSettingIntent } from './use-session-setting-intent'; import { projectSessionModelSettings, + type SessionModelTarget, useSessionModelSettingsIntent, } from './use-session-model-settings-intent'; import { deriveStaleSessionIds } from './stale-sessions'; @@ -756,7 +757,7 @@ function AppShellContent({ refreshCatalog: refreshSessions, saveComposerModel: (model) => saveComposerDefaults({ model }), onModelError: (sessionId, error) => { - if (activeIdRef.current !== sessionId) return; + if (!isComposerImportOwnerActive({ sessionId, navSection: 'sessions' })) return; showSessionError( sessionId, sessionSettingsCopy.modelFailedTitle, @@ -764,7 +765,7 @@ function AppShellContent({ ); }, onThinkingError: (sessionId, error) => { - if (activeIdRef.current !== sessionId) return; + if (!isComposerImportOwnerActive({ sessionId, navSection: 'sessions' })) return; showSessionError( sessionId, sessionSettingsCopy.thinkingFailedTitle, @@ -1793,6 +1794,7 @@ function AppShellContent({ setInteractionBySession, preSendSettingsRef, sendCancellationEpochRef, + readSessionModelSettings: modelSettingsIntent.read, settleSessionModelSettings: modelSettingsIntent.settle, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -1849,8 +1851,18 @@ function AppShellContent({ toastApi, }); - async function taskSubmissionReadyAtSend(): Promise { - const snapshot = await taskReadiness.checkNow(); + async function taskSubmissionReadyAtSend( + model?: SessionModelTarget, + ): Promise { + const snapshot = await taskReadiness.checkNow( + model + ? { + ...taskReadinessRequest, + connectionSlug: model.llmConnectionSlug, + model: model.model, + } + : undefined, + ); return !isTaskSubmissionHardBlocked(snapshot, { ignoreModelTarget: ignoreTaskReadinessModelTarget, }); diff --git a/apps/desktop/src/renderer/use-session-model-settings-intent.ts b/apps/desktop/src/renderer/use-session-model-settings-intent.ts index 2ed1c4d2c8..827dbdd2f0 100644 --- a/apps/desktop/src/renderer/use-session-model-settings-intent.ts +++ b/apps/desktop/src/renderer/use-session-model-settings-intent.ts @@ -47,6 +47,7 @@ export interface SessionModelSettingsIntentOptions { export interface SessionModelSettingsIntentController { overlayBySession: Readonly>; + read(sessionId: string): SessionModelSettings | undefined; selectModel(sessionId: string, model: SessionModelTarget): void; selectThinkingLevel(sessionId: string, level: ThinkingLevel | undefined): void; settle(sessionId: string): Promise; @@ -56,6 +57,9 @@ export interface SessionModelSettingsIntentController { interface ModelSettingsIntent { desired: SessionModelSettings; committed: SessionModelSettings; + nextVersion: number; + pendingModelVersion?: number; + pendingThinkingVersion?: number; committedAtCatalogRevision?: number; inFlight?: Promise; } @@ -81,23 +85,38 @@ function sameSettings( return sameModel(left.model, right.model) && left.thinkingLevel === right.thinkingLevel; } -function rebaseUncommittedFields( +function applyAuthoritativeSettings( intent: ModelSettingsIntent, authoritative: SessionModelSettings, ): boolean { const previousDesired = intent.desired; - const ownsModel = !sameModel(previousDesired.model, intent.committed.model); - const ownsThinking = previousDesired.thinkingLevel !== intent.committed.thinkingLevel; intent.committed = authoritative; intent.desired = { - model: ownsModel ? previousDesired.model : authoritative.model, - thinkingLevel: ownsThinking + model: intent.pendingModelVersion !== undefined + ? previousDesired.model + : authoritative.model, + thinkingLevel: intent.pendingThinkingVersion !== undefined ? previousDesired.thinkingLevel : authoritative.thinkingLevel, }; return !sameSettings(previousDesired, intent.desired); } +function hasPendingSettings(intent: ModelSettingsIntent): boolean { + return intent.pendingModelVersion !== undefined || + intent.pendingThinkingVersion !== undefined; +} + +function responsePredatesCatalogModel( + intent: ModelSettingsIntent, + response: SessionModelSettings, + catalogRevisionAtStart: number, +): boolean { + return intent.committedAtCatalogRevision !== undefined && + intent.committedAtCatalogRevision > catalogRevisionAtStart && + !sameModel(intent.committed.model, response.model); +} + export function projectSessionModelSettings( session: T, overlay: SessionModelSettings | undefined, @@ -156,7 +175,7 @@ export function useSessionModelSettingsIntent( if (intent.inFlight) { const authoritative = optionsRef.current.readAuthoritative(sessionId); if (authoritative) { - const rebased = rebaseUncommittedFields(intent, settingsFromSession(authoritative)); + const rebased = applyAuthoritativeSettings(intent, settingsFromSession(authoritative)); intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; if (rebased) setOverlay(sessionId, intent.desired); } @@ -180,6 +199,8 @@ export function useSessionModelSettingsIntent( field: 'model' | 'thinking', error: unknown, ): false => { + intent.pendingModelVersion = undefined; + intent.pendingThinkingVersion = undefined; intent.desired = intent.committed; if (field === 'model') optionsRef.current.onModelError(sessionId, error); else optionsRef.current.onThinkingError(sessionId, error); @@ -199,41 +220,66 @@ export function useSessionModelSettingsIntent( intent: ModelSettingsIntent, ): Promise => { while (intentsRef.current.get(sessionId) === intent) { - const before = intent.committed; const desired = intent.desired; - if (!sameModel(before.model, desired.model)) { + if (intent.pendingModelVersion !== undefined) { const attempted = desired.model; + const attemptedModelVersion = intent.pendingModelVersion; + const attemptedThinkingVersion = intent.pendingThinkingVersion; + const catalogRevisionAtStart = optionsRef.current.catalogRevision; try { const result = await optionsRef.current.setModel(sessionId, attempted); if (intentsRef.current.get(sessionId) !== intent) return false; - intent.committed = settingsFromSession(result); + const authoritative = settingsFromSession(result); + if (responsePredatesCatalogModel(intent, authoritative, catalogRevisionAtStart)) { + continue; + } + if ( + intent.pendingModelVersion === attemptedModelVersion && + sameModel(intent.desired.model, authoritative.model) + ) { + intent.pendingModelVersion = undefined; + } + if ( + intent.pendingThinkingVersion === attemptedThinkingVersion && + intent.desired.thinkingLevel === authoritative.thinkingLevel + ) { + intent.pendingThinkingVersion = undefined; + } + applyAuthoritativeSettings(intent, authoritative); intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; optionsRef.current.saveComposerModel(attempted); } catch (error) { if (intentsRef.current.get(sessionId) !== intent) return false; - if (sameModel(intent.desired.model, attempted)) { + if (intent.pendingModelVersion === attemptedModelVersion) { return failLatest(sessionId, intent, 'model', error); } } continue; } - if (before.thinkingLevel !== desired.thinkingLevel) { + if (intent.pendingThinkingVersion !== undefined) { const attempted = desired.thinkingLevel; const attemptedModel = desired.model; + const attemptedThinkingVersion = intent.pendingThinkingVersion; + const catalogRevisionAtStart = optionsRef.current.catalogRevision; try { const result = await optionsRef.current.setThinkingLevel(sessionId, attempted); if (intentsRef.current.get(sessionId) !== intent) return false; - const committed = settingsFromSession(result); + const authoritative = settingsFromSession(result); + if (responsePredatesCatalogModel(intent, authoritative, catalogRevisionAtStart)) { + continue; + } if ( - sameModel(intent.desired.model, committed.model) && - intent.desired.thinkingLevel === committed.thinkingLevel + intent.pendingThinkingVersion === attemptedThinkingVersion && + intent.desired.thinkingLevel === authoritative.thinkingLevel ) { - intent.committed = committed; - intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; + intent.pendingThinkingVersion = undefined; } + applyAuthoritativeSettings(intent, authoritative); + intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; } catch (error) { if (intentsRef.current.get(sessionId) !== intent) return false; if ( + intent.pendingThinkingVersion === attemptedThinkingVersion && sameModel(intent.desired.model, attemptedModel) && intent.desired.thinkingLevel === attempted ) { @@ -269,7 +315,7 @@ export function useSessionModelSettingsIntent( return result; } intent.inFlight = undefined; - if (!sameSettings(intent.committed, intent.desired)) { + if (hasPendingSettings(intent)) { const continuation = launch(); intent.inFlight = continuation; return continuation; @@ -299,7 +345,7 @@ export function useSessionModelSettingsIntent( const authoritative = optionsRef.current.readAuthoritative(sessionId); if (!authoritative) return undefined; const committed = settingsFromSession(authoritative); - const intent = { committed, desired: committed }; + const intent = { committed, desired: committed, nextVersion: 0 }; intentsRef.current.set(sessionId, intent); return intent; }, []); @@ -307,7 +353,10 @@ export function useSessionModelSettingsIntent( const selectModel = useCallback((sessionId: string, model: SessionModelTarget): void => { const intent = getOrCreateIntent(sessionId); if (!intent) return; + const version = ++intent.nextVersion; intent.desired = { model, thinkingLevel: undefined }; + intent.pendingModelVersion = version; + intent.pendingThinkingVersion = version; setOverlay(sessionId, intent.desired); startWorker(sessionId, intent); }, [getOrCreateIntent, setOverlay, startWorker]); @@ -318,6 +367,7 @@ export function useSessionModelSettingsIntent( ): void => { const intent = getOrCreateIntent(sessionId); if (!intent) return; + intent.pendingThinkingVersion = ++intent.nextVersion; intent.desired = { ...intent.desired, thinkingLevel }; setOverlay(sessionId, intent.desired); startWorker(sessionId, intent); @@ -326,10 +376,16 @@ export function useSessionModelSettingsIntent( const settle = useCallback(async (sessionId: string): Promise => { return intentsRef.current.get(sessionId)?.inFlight ?? true; }, []); + const read = useCallback((sessionId: string): SessionModelSettings | undefined => { + const intent = intentsRef.current.get(sessionId); + if (intent) return intent.desired; + const authoritative = optionsRef.current.readAuthoritative(sessionId); + return authoritative ? settingsFromSession(authoritative) : undefined; + }, []); const clear = useCallback((sessionId: string): void => { intentsRef.current.delete(sessionId); setOverlay(sessionId, undefined); }, [setOverlay]); - return { overlayBySession, selectModel, selectThinkingLevel, settle, clear }; + return { overlayBySession, read, selectModel, selectThinkingLevel, settle, clear }; } diff --git a/apps/desktop/src/renderer/use-task-submission-readiness.ts b/apps/desktop/src/renderer/use-task-submission-readiness.ts index d95a5a7018..0dee1e8055 100644 --- a/apps/desktop/src/renderer/use-task-submission-readiness.ts +++ b/apps/desktop/src/renderer/use-task-submission-readiness.ts @@ -35,13 +35,16 @@ export function useTaskSubmissionReadiness( const requestSequence = useRef(0); const refresh = useCallback(() => setRevision((value) => value + 1), []); - const checkNow = useCallback(async () => { + const checkNow = useCallback(async ( + requestOverride?: DesktopTaskSubmissionReadinessRequest, + ) => { const sequence = ++requestSequence.current; + const requestAtCheck = requestOverride ?? request; try { const next = sessionId - ? await window.maka.taskReadiness.getSnapshot(request, sessionId) + ? await window.maka.taskReadiness.getSnapshot(requestAtCheck, sessionId) : newTaskTarget - ? await window.maka.newTasks.getReadiness(newTaskTarget, request) + ? await window.maka.newTasks.getReadiness(newTaskTarget, requestAtCheck) : undefined; if (requestSequence.current === sequence) setSnapshot(next); return next; From 001df6bc93292b833a450f85fe3ff3ffe62fc50b Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:13:42 +0800 Subject: [PATCH 06/24] fix(desktop): close model-setting surface races Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 173 ++++++++++++++++++ .../app-shell-first-send-cleanup.test.ts | 1 + .../model-settings-visual-contract.test.ts | 50 ++++- .../session-model-settings-intent.test.ts | 41 ++++- .../src/renderer/app-shell-chat-actions.ts | 80 ++++++-- .../desktop/src/renderer/app-shell-effects.ts | 2 +- apps/desktop/src/renderer/app-shell.tsx | 18 +- .../src/renderer/shell-surface-owner.ts | 39 ++++ .../use-session-model-settings-intent.ts | 33 +++- 9 files changed, 407 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/renderer/shell-surface-owner.ts diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index cc393c765c..d151a3b26b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -114,6 +114,7 @@ function createActionsDeps() { setInteractionBySession: () => undefined, preSendSettingsRef: { current: new Map() }, sendCancellationEpochRef: { current: new Map() }, + hasSessionModelIntent: () => false, readSessionModelSettings: () => undefined, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, @@ -178,6 +179,7 @@ describe('busy-raced send settlement', () => { it('cancels a pre-send settings wait before it reaches Runtime Host', async () => { const settlement = deferred(); + let readinessCalls = 0; let sessionSendCalls = 0; const turnState = createTurnState(); const restoreWindow = installWindow({ @@ -197,6 +199,10 @@ describe('busy-raced send settlement', () => { const actions = createAppShellChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, + checkTaskSubmissionReadiness: async () => { + readinessCalls += 1; + return true; + }, setLiveTurnBySession: turnState.setLiveTurnBySession, settleSessionModelSettings: () => settlement.promise, }); @@ -213,6 +219,7 @@ describe('busy-raced send settlement', () => { settlement.resolve(true); assert.equal(await sending, false); + assert.equal(readinessCalls, 0); assert.equal(sessionSendCalls, 0); assert.equal(turnState.liveTurnBySession['session-a'], undefined); } finally { @@ -220,6 +227,48 @@ describe('busy-raced send settlement', () => { } }); + it('cancels during readiness without starting another settings settlement', async () => { + const readiness = deferred(); + let settleCalls = 0; + let sessionSendCalls = 0; + const restoreWindow = installWindow({ + sessions: { + send: async () => { + sessionSendCalls += 1; + return { + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + checkTaskSubmissionReadiness: () => readiness.promise, + settleSessionModelSettings: async () => { + settleCalls += 1; + return true; + }, + }); + + const sending = actions.send('hello'); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(actions.cancelPendingSend('session-a'), true); + readiness.resolve(true); + + assert.equal(await sending, false); + assert.equal(settleCalls, 1); + assert.equal(sessionSendCalls, 0); + } finally { + restoreWindow(); + } + }); + it('does not dispatch after the composer surface owner changes during settlement', async () => { const settlement = deferred(); let ownsSurface = true; @@ -282,6 +331,7 @@ describe('busy-raced send settlement', () => { const deps = { ...createActionsDeps(), activeIdRef: { current: 'session-a' }, + hasSessionModelIntent: () => true, checkTaskSubmissionReadiness: async (model?: { llmConnectionSlug: string; model: string; @@ -312,6 +362,8 @@ describe('busy-raced send settlement', () => { 'settle:start', 'settle:end', 'readiness:gpt-5.6-sol', + 'settle:start', + 'settle:end', ]); assert.equal(sessionSendCalls, 1); } finally { @@ -319,6 +371,127 @@ describe('busy-raced send settlement', () => { } }); + it('preserves the shell readiness target when no local model intent exists', async () => { + const readinessTargets: Array = []; + const restoreWindow = installWindow({ + sessions: { + send: async () => ({ + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }), + }, + }); + try { + const deps = { + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + checkTaskSubmissionReadiness: async (target?: { + llmConnectionSlug: string; + model: string; + }) => { + readinessTargets.push(target?.model); + return true; + }, + hasSessionModelIntent: () => false, + readSessionModelSettings: () => ({ + model: { llmConnectionSlug: 'legacy', model: 'obsolete' }, + thinkingLevel: undefined, + }), + }; + + assert.equal(await createAppShellChatActions(deps).send('hello'), true); + assert.deepEqual(readinessTargets, [undefined]); + } finally { + restoreWindow(); + } + }); + + it('re-settles when model settings change during readiness', async () => { + let model = 'gpt-5.5'; + let settleCalls = 0; + const readinessTargets: string[] = []; + let sessionSendCalls = 0; + const restoreWindow = installWindow({ + sessions: { + send: async () => { + sessionSendCalls += 1; + return { + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + hasSessionModelIntent: () => true, + checkTaskSubmissionReadiness: async (target) => { + readinessTargets.push(target?.model ?? 'missing'); + if (readinessTargets.length === 1) model = 'gpt-5.6-sol'; + return true; + }, + readSessionModelSettings: () => ({ + model: { llmConnectionSlug: 'openai', model }, + thinkingLevel: undefined, + }), + settleSessionModelSettings: async () => { + settleCalls += 1; + return true; + }, + }); + + assert.equal(await actions.send('hello'), true); + assert.ok(settleCalls >= 2); + assert.deepEqual(readinessTargets, ['gpt-5.5', 'gpt-5.6-sol']); + assert.equal(sessionSendCalls, 1); + } finally { + restoreWindow(); + } + }); + + it('does not dispatch when the chat surface becomes obscured during readiness', async () => { + let ownsSurface = true; + let sessionSendCalls = 0; + const turnState = createTurnState(); + const restoreWindow = installWindow({ + sessions: { + send: async () => { + sessionSendCalls += 1; + return { + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + checkTaskSubmissionReadiness: async () => { + ownsSurface = false; + return true; + }, + isShellSurfaceOwnerActive: () => ownsSurface, + setLiveTurnBySession: turnState.setLiveTurnBySession, + }); + + assert.equal(await actions.send('hello'), false); + assert.equal(sessionSendCalls, 0); + assert.equal(turnState.liveTurnBySession['session-a'], undefined); + } finally { + restoreWindow(); + } + }); + it('disarms processing and skips send when model settings fail to settle', async () => { let sessionSendCalls = 0; const turnState = createTurnState(); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 8f809e0e7e..85e067e215 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -107,6 +107,7 @@ function createActionsDeps() { setInteractionBySession: () => undefined, preSendSettingsRef: { current: new Map() }, sendCancellationEpochRef: { current: new Map() }, + hasSessionModelIntent: () => false, readSessionModelSettings: () => undefined, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, diff --git a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts index 73edfc9b52..1d4ee9f226 100644 --- a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import { test } from 'node:test'; +import { isShellSurfaceOwnerCurrent } from '../../renderer/shell-surface-owner.js'; function readFirst(candidates: URL[]): string { const sourceUrl = candidates.find((candidate) => existsSync(candidate)); @@ -40,6 +41,10 @@ const appShellSource = readFirst([ new URL('../../renderer/app-shell.tsx', import.meta.url), new URL('../../../src/renderer/app-shell.tsx', import.meta.url), ]); +const appShellEffectsSource = readFirst([ + new URL('../../renderer/app-shell-effects.ts', import.meta.url), + new URL('../../../src/renderer/app-shell-effects.ts', import.meta.url), +]); const modelSettingsIntentSource = readFirst([ new URL('../../renderer/use-session-model-settings-intent.ts', import.meta.url), new URL('../../../src/renderer/use-session-model-settings-intent.ts', import.meta.url), @@ -82,10 +87,45 @@ test('model setting failures stay on the chat surface that owns the session', () appShellSource.indexOf('const modelSettingsIntent = useSessionModelSettingsIntent'), appShellSource.indexOf('const activeSessionForModelControls'), ); - const surfaceGuards = intentOptions.match( - /isComposerImportOwnerActive\(\{\s*sessionId,\s*navSection:\s*'sessions',?\s*\}\)/g, - ); + const surfaceGuards = intentOptions.match(/isComposerImportOwnerActive\(\{/g); assert.equal(surfaceGuards?.length, 2); + assert.match(appShellSource, /return isShellSurfaceOwnerCurrent\(\{/); +}); + +test('session setting error surface rejects every obscured or replaced chat state', () => { + const visible = { + owner: { sessionId: 'session-a', navSection: 'sessions' }, + currentSessionId: 'session-a', + currentNavSection: 'sessions', + currentNewTaskDraftKey: 'draft-a', + shellObscured: false, + workHubActive: false, + }; + assert.equal(isShellSurfaceOwnerCurrent(visible), true); + assert.equal(isShellSurfaceOwnerCurrent({ ...visible, shellObscured: true }), false); + assert.equal(isShellSurfaceOwnerCurrent({ ...visible, workHubActive: true }), false); + assert.equal(isShellSurfaceOwnerCurrent({ + ...visible, + currentNavSection: 'modules', + }), false); + assert.equal(isShellSurfaceOwnerCurrent({ + ...visible, + currentSessionId: 'session-b', + }), false); +}); + +test('navigation ownership is published before async results can resume', () => { + assert.match( + appShellEffectsSource, + /useAppShellNavRefSync[\s\S]*?useLayoutEffect\(\(\) => \{\s*options\.navSelectionRef\.current/, + ); +}); + +test('settled model readiness does not ignore model-target blockers', () => { + assert.match( + appShellSource, + /ignoreModelTarget:\s*model\s*\?\s*false\s*:\s*ignoreTaskReadinessModelTarget/, + ); }); test('optimistic settings stay inside committed model-control state', () => { @@ -93,6 +133,10 @@ test('optimistic settings stay inside committed model-control state', () => { modelSettingsIntentSource, /useLayoutEffect\(\(\) => \{\s*optionsRef\.current = options;\s*\}\);/, ); + assert.match( + modelSettingsIntentSource, + /useLayoutEffect\(\(\) => \{\s*for \(const \[sessionId, intent\] of intentsRef\.current\)/, + ); assert.match(appShellSource, /sessionHealthSession:\s*activeSession/); assert.match( appShellSource, diff --git a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts index 1c19b4764e..78ed1e2422 100644 --- a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts +++ b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts @@ -21,7 +21,7 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { SessionSummary } from '@maka/core/session'; -import { act, createElement } from 'react'; +import { act, createElement, useLayoutEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { @@ -567,6 +567,40 @@ test('ignores a stale thinking success from the model replaced by another client }); }); +test('fences a mutation response when the catalog changed before passive reconciliation', async () => { + const firstThinkingWrite = deferred(); + const thinkingWrites: Array = []; + const harness = await mountIntent({ + setThinkingLevel: async (_sessionId, level) => { + thinkingWrites.push(level); + if (thinkingWrites.length === 1) return firstThinkingWrite.promise; + return session({ thinkingLevel: level }); + }, + refreshCatalog: async () => { + throw new Error('catalog refresh unavailable'); + }, + }); + + await act(async () => { + harness.controller().selectThinkingLevel('session-a', 'high'); + }); + await harness.render(1, session({ + thinkingLevel: 'low', + }), () => { + firstThinkingWrite.resolve(session({ thinkingLevel: 'high' })); + }); + + await act(async () => { + assert.equal(await harness.controller().settle('session-a'), true); + }); + + assert.deepEqual(thinkingWrites, ['high', 'high']); + assert.deepEqual(harness.controller().overlayBySession['session-a'], { + model: { llmConnectionSlug: 'anthropic', model: 'claude-sonnet' }, + thinkingLevel: 'high', + }); +}); + test('adopts the authoritative model returned by a thinking mutation without retrying', async () => { const thinkingWrites: Array = []; const harness = await mountIntent({ @@ -822,6 +856,7 @@ async function mountIntent(overrides: Partial = {}) { const render = async ( catalogRevision: number, nextAuthoritative: SessionSummary = authoritative, + afterLayout?: () => void, ) => { authoritative = nextAuthoritative; const options: SessionModelSettingsIntentOptions = { @@ -833,6 +868,7 @@ async function mountIntent(overrides: Partial = {}) { await act(async () => { mountedRoot?.render(createElement(Harness, { options, + afterLayout, capture: (controller) => { captured = controller; }, @@ -860,12 +896,15 @@ async function mountIntent(overrides: Partial = {}) { function Harness({ options, + afterLayout, capture, }: { options: SessionModelSettingsIntentOptions; + afterLayout?: () => void; capture(controller: SessionModelSettingsIntentController): void; }) { const controller = useSessionModelSettingsIntent(options); + useLayoutEffect(() => afterLayout?.(), [afterLayout]); capture(controller); return createElement('output', { 'data-model': controller.overlayBySession['session-a']?.model.model, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 587c66143e..9dbd25d607 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -93,6 +93,16 @@ type SessionModelSettings = { thinkingLevel: ThinkingLevel | undefined; }; +function sameSessionModelSettings( + left: SessionModelSettings | undefined, + right: SessionModelSettings | undefined, +): boolean { + if (!left || !right) return left === right; + return left.model.llmConnectionSlug === right.model.llmConnectionSlug && + left.model.model === right.model.model && + left.thinkingLevel === right.thinkingLevel; +} + type PendingNewChatThinkingLevel = ThinkingLevel | null; type ToastApi = { @@ -158,6 +168,7 @@ export function createAppShellChatActions(deps: { setInteractionBySession: InteractionQueueUpdater; preSendSettingsRef: RefBox>; sendCancellationEpochRef: RefBox>; + hasSessionModelIntent(sessionId: string): boolean; readSessionModelSettings(sessionId: string): SessionModelSettings | undefined; settleSessionModelSettings(sessionId: string): Promise; onInteractionChanged?: (sessionId: string) => void; @@ -208,6 +219,7 @@ export function createAppShellChatActions(deps: { setInteractionBySession, preSendSettingsRef, sendCancellationEpochRef, + hasSessionModelIntent, readSessionModelSettings, settleSessionModelSettings, onInteractionChanged, @@ -524,22 +536,62 @@ export function createAppShellChatActions(deps: { optimisticTurnId = turnId; preSendSettingsRef.current.set(sessionId, turnId); armTurnActive(sessionId, turnId); - let settingsSettled: boolean; - try { - settingsSettled = await settleSessionModelSettings(sessionId); - } catch (error) { - finishPendingSend(sessionId, turnId); - throw error; - } - if (!settingsSettled || !isShellSurfaceOwnerActive(sendOwner)) { - finishPendingSend(sessionId, turnId); - disarmTurnActive(sessionId, turnId); - return false; + let overrideReadinessModel = hasSessionModelIntent(sessionId); + while (true) { + let settingsSettled: boolean; + try { + settingsSettled = await settleSessionModelSettings(sessionId); + } catch (error) { + finishPendingSend(sessionId, turnId); + throw error; + } + if (preSendSettingsRef.current.get(sessionId) !== turnId) return false; + if (!settingsSettled || !isShellSurfaceOwnerActive(sendOwner)) { + finishPendingSend(sessionId, turnId); + disarmTurnActive(sessionId, turnId); + return false; + } + const settingsBeforeReadiness = readSessionModelSettings(sessionId); + const submissionReady = await checkTaskSubmissionReadiness( + overrideReadinessModel ? settingsBeforeReadiness?.model : undefined, + ); + if (preSendSettingsRef.current.get(sessionId) !== turnId) return false; + try { + settingsSettled = await settleSessionModelSettings(sessionId); + } catch (error) { + finishPendingSend(sessionId, turnId); + throw error; + } + if (preSendSettingsRef.current.get(sessionId) !== turnId) return false; + if (!settingsSettled || !isShellSurfaceOwnerActive(sendOwner)) { + finishPendingSend(sessionId, turnId); + disarmTurnActive(sessionId, turnId); + return false; + } + const settingsAfterReadiness = readSessionModelSettings(sessionId); + if (!sameSessionModelSettings(settingsBeforeReadiness, settingsAfterReadiness)) { + if ( + settingsBeforeReadiness?.model.llmConnectionSlug !== + settingsAfterReadiness?.model.llmConnectionSlug || + settingsBeforeReadiness?.model.model !== settingsAfterReadiness?.model.model + ) { + overrideReadinessModel = true; + } + continue; + } + if (!overrideReadinessModel && hasSessionModelIntent(sessionId)) { + overrideReadinessModel = true; + continue; + } + if (!submissionReady) { + finishPendingSend(sessionId, turnId); + disarmTurnActive(sessionId, turnId); + return false; + } + break; } - const settledModel = readSessionModelSettings(sessionId)?.model; - const submissionReady = await checkTaskSubmissionReadiness(settledModel); if (!finishPendingSend(sessionId, turnId)) return false; - if (!submissionReady || !isShellSurfaceOwnerActive(sendOwner)) { + if (!isShellSurfaceOwnerActive(sendOwner)) { disarmTurnActive(sessionId, turnId); return false; } diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 588225d4f4..79da7df68c 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -84,7 +84,7 @@ type ToastApi = { }; export function useAppShellNavRefSync(options: { navSelection: NavSelection; navSelectionRef: RefBox }) { - useEffect(() => { + useLayoutEffect(() => { options.navSelectionRef.current = options.navSelection; }, [options.navSelection]); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 3ad0624622..69c645221f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -132,6 +132,7 @@ import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { useShellSearch } from './use-shell-search'; import { useSessionSettingIntent } from './use-session-setting-intent'; +import { isShellSurfaceOwnerCurrent } from './shell-surface-owner'; import { projectSessionModelSettings, type SessionModelTarget, @@ -1662,6 +1663,10 @@ function AppShellContent({ const hasModalOpen = helpOpen || paletteOpen || searchModalOpen; const shellObscured = hasModalOpen || settingsOpen; + const shellSurfaceStateRef = useRef({ shellObscured, workHubActive }); + useLayoutEffect(() => { + shellSurfaceStateRef.current = { shellObscured, workHubActive }; + }, [shellObscured, workHubActive]); const contextCompactionPresentation = useMemo( () => createContextCompactionPresentation({ @@ -1794,6 +1799,7 @@ function AppShellContent({ setInteractionBySession, preSendSettingsRef, sendCancellationEpochRef, + hasSessionModelIntent: modelSettingsIntent.hasModelIntent, readSessionModelSettings: modelSettingsIntent.read, settleSessionModelSettings: modelSettingsIntent.settle, onInteractionChanged: markInteractionChanged, @@ -1864,7 +1870,7 @@ function AppShellContent({ : undefined, ); return !isTaskSubmissionHardBlocked(snapshot, { - ignoreModelTarget: ignoreTaskReadinessModelTarget, + ignoreModelTarget: model ? false : ignoreTaskReadinessModelTarget, }); } @@ -2457,9 +2463,13 @@ function AppShellContent({ * send pull a user out of 技能 and into 设置 · 模型. */ function isShellSurfaceOwnerActive(owner: ComposerImportOwner): boolean { - return navSelectionRef.current.section === owner.navSection && - activeIdRef.current === owner.sessionId && - (owner.sessionId !== undefined || owner.newTaskDraftKey === currentNewTaskDraftKey); + return isShellSurfaceOwnerCurrent({ + owner, + currentNavSection: navSelectionRef.current.section, + currentSessionId: activeIdRef.current, + currentNewTaskDraftKey, + ...shellSurfaceStateRef.current, + }); } /** …and the owner was captured on the chat surface. */ diff --git a/apps/desktop/src/renderer/shell-surface-owner.ts b/apps/desktop/src/renderer/shell-surface-owner.ts new file mode 100644 index 0000000000..cf8b628d33 --- /dev/null +++ b/apps/desktop/src/renderer/shell-surface-owner.ts @@ -0,0 +1,39 @@ +/* + * 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. + */ + +interface ShellSurfaceOwner { + sessionId: string | undefined; + navSection: string; + newTaskDraftKey?: string; +} + +export function isShellSurfaceOwnerCurrent(input: { + owner: ShellSurfaceOwner; + currentSessionId: string | undefined; + currentNavSection: string; + currentNewTaskDraftKey: string; + shellObscured: boolean; + workHubActive: boolean; +}): boolean { + if (input.shellObscured || input.workHubActive) return false; + return input.currentNavSection === input.owner.navSection && + input.currentSessionId === input.owner.sessionId && + (input.owner.sessionId !== undefined || + input.owner.newTaskDraftKey === input.currentNewTaskDraftKey); +} diff --git a/apps/desktop/src/renderer/use-session-model-settings-intent.ts b/apps/desktop/src/renderer/use-session-model-settings-intent.ts index 827dbdd2f0..f2dca9e4c4 100644 --- a/apps/desktop/src/renderer/use-session-model-settings-intent.ts +++ b/apps/desktop/src/renderer/use-session-model-settings-intent.ts @@ -47,6 +47,7 @@ export interface SessionModelSettingsIntentOptions { export interface SessionModelSettingsIntentController { overlayBySession: Readonly>; + hasModelIntent(sessionId: string): boolean; read(sessionId: string): SessionModelSettings | undefined; selectModel(sessionId: string, model: SessionModelTarget): void; selectThinkingLevel(sessionId: string, level: ThinkingLevel | undefined): void; @@ -58,6 +59,7 @@ interface ModelSettingsIntent { desired: SessionModelSettings; committed: SessionModelSettings; nextVersion: number; + hasModelSelection: boolean; pendingModelVersion?: number; pendingThinkingVersion?: number; committedAtCatalogRevision?: number; @@ -107,14 +109,14 @@ function hasPendingSettings(intent: ModelSettingsIntent): boolean { intent.pendingThinkingVersion !== undefined; } -function responsePredatesCatalogModel( +function responsePredatesCatalogSettings( intent: ModelSettingsIntent, response: SessionModelSettings, catalogRevisionAtStart: number, ): boolean { return intent.committedAtCatalogRevision !== undefined && intent.committedAtCatalogRevision > catalogRevisionAtStart && - !sameModel(intent.committed.model, response.model); + !sameSettings(intent.committed, response); } export function projectSessionModelSettings( @@ -170,7 +172,7 @@ export function useSessionModelSettingsIntent( setOverlay(sessionId, undefined); }, [setOverlay]); - useEffect(() => { + useLayoutEffect(() => { for (const [sessionId, intent] of intentsRef.current) { if (intent.inFlight) { const authoritative = optionsRef.current.readAuthoritative(sessionId); @@ -230,7 +232,7 @@ export function useSessionModelSettingsIntent( const result = await optionsRef.current.setModel(sessionId, attempted); if (intentsRef.current.get(sessionId) !== intent) return false; const authoritative = settingsFromSession(result); - if (responsePredatesCatalogModel(intent, authoritative, catalogRevisionAtStart)) { + if (responsePredatesCatalogSettings(intent, authoritative, catalogRevisionAtStart)) { continue; } if ( @@ -265,7 +267,7 @@ export function useSessionModelSettingsIntent( const result = await optionsRef.current.setThinkingLevel(sessionId, attempted); if (intentsRef.current.get(sessionId) !== intent) return false; const authoritative = settingsFromSession(result); - if (responsePredatesCatalogModel(intent, authoritative, catalogRevisionAtStart)) { + if (responsePredatesCatalogSettings(intent, authoritative, catalogRevisionAtStart)) { continue; } if ( @@ -345,7 +347,12 @@ export function useSessionModelSettingsIntent( const authoritative = optionsRef.current.readAuthoritative(sessionId); if (!authoritative) return undefined; const committed = settingsFromSession(authoritative); - const intent = { committed, desired: committed, nextVersion: 0 }; + const intent = { + committed, + desired: committed, + nextVersion: 0, + hasModelSelection: false, + }; intentsRef.current.set(sessionId, intent); return intent; }, []); @@ -354,6 +361,7 @@ export function useSessionModelSettingsIntent( const intent = getOrCreateIntent(sessionId); if (!intent) return; const version = ++intent.nextVersion; + intent.hasModelSelection = true; intent.desired = { model, thinkingLevel: undefined }; intent.pendingModelVersion = version; intent.pendingThinkingVersion = version; @@ -382,10 +390,21 @@ export function useSessionModelSettingsIntent( const authoritative = optionsRef.current.readAuthoritative(sessionId); return authoritative ? settingsFromSession(authoritative) : undefined; }, []); + const hasModelIntent = useCallback((sessionId: string): boolean => { + return intentsRef.current.get(sessionId)?.hasModelSelection === true; + }, []); const clear = useCallback((sessionId: string): void => { intentsRef.current.delete(sessionId); setOverlay(sessionId, undefined); }, [setOverlay]); - return { overlayBySession, read, selectModel, selectThinkingLevel, settle, clear }; + return { + overlayBySession, + hasModelIntent, + read, + selectModel, + selectThinkingLevel, + settle, + clear, + }; } From b1ae2a754f14e3c2efef5e8b78e9b9f9e6ac4b33 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:29:03 +0800 Subject: [PATCH 07/24] fix(desktop): retain model-setting settlement results Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 103 +++++++++++++++++- .../app-shell-first-send-cleanup.test.ts | 2 +- .../session-model-settings-intent.test.ts | 8 +- .../src/renderer/app-shell-chat-actions.ts | 24 ++-- apps/desktop/src/renderer/app-shell.tsx | 2 +- .../use-session-model-settings-intent.ts | 38 ++++--- 6 files changed, 146 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index d151a3b26b..fb94f1175e 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -31,6 +31,7 @@ import { describe, it } from 'node:test'; import type { StoredMessage } from '@maka/core/session'; import type { LiveTurnProjection } from '@maka/ui'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; +import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; function installWindow(maka: unknown): () => void { const target = globalThis as unknown as { window?: unknown }; @@ -114,7 +115,7 @@ function createActionsDeps() { setInteractionBySession: () => undefined, preSendSettingsRef: { current: new Map() }, sendCancellationEpochRef: { current: new Map() }, - hasSessionModelIntent: () => false, + hasSessionModelOverride: () => false, readSessionModelSettings: () => undefined, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, @@ -177,6 +178,61 @@ describe('busy-raced send settlement', () => { } }); + it('remembers a settings failure that settles while a sparse transcript loads', async () => { + const mutation = deferred(); + const latest = deferred(); + let mutationStillPending = true; + let sessionSendCalls = 0; + const order: string[] = []; + const transcript = { + store: { + range: () => ({ sessionId: 'session-a', hasNewer: true }), + snapshot: () => ({ messages: [] }), + }, + async loadLatest() { + order.push('latest'); + await latest.promise; + }, + } as unknown as DesktopTranscriptRangeController; + const restoreWindow = installWindow({ + sessions: { + send: async () => { + sessionSendCalls += 1; + return { + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }; + }, + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + transcriptRangeRef: { current: transcript }, + settleSessionModelSettings: () => { + order.push('settle'); + return mutationStillPending ? mutation.promise : Promise.resolve(true); + }, + }); + + const sending = actions.send('hello'); + await Promise.resolve(); + assert.deepEqual(order, ['settle', 'latest']); + + mutationStillPending = false; + mutation.resolve(false); + latest.resolve(); + + assert.equal(await sending, false); + assert.equal(sessionSendCalls, 0); + } finally { + restoreWindow(); + } + }); + it('cancels a pre-send settings wait before it reaches Runtime Host', async () => { const settlement = deferred(); let readinessCalls = 0; @@ -331,7 +387,7 @@ describe('busy-raced send settlement', () => { const deps = { ...createActionsDeps(), activeIdRef: { current: 'session-a' }, - hasSessionModelIntent: () => true, + hasSessionModelOverride: () => true, checkTaskSubmissionReadiness: async (model?: { llmConnectionSlug: string; model: string; @@ -394,7 +450,7 @@ describe('busy-raced send settlement', () => { readinessTargets.push(target?.model); return true; }, - hasSessionModelIntent: () => false, + hasSessionModelOverride: () => false, readSessionModelSettings: () => ({ model: { llmConnectionSlug: 'legacy', model: 'obsolete' }, thinkingLevel: undefined, @@ -408,6 +464,45 @@ describe('busy-raced send settlement', () => { } }); + it('checks readiness against a model adopted from a thinking mutation', async () => { + let hasModelOverlay = false; + const readinessTargets: Array = []; + const restoreWindow = installWindow({ + sessions: { + send: async () => ({ + ok: true, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + hasSessionModelOverride: () => hasModelOverlay, + checkTaskSubmissionReadiness: async (target) => { + readinessTargets.push(target?.model); + return true; + }, + readSessionModelSettings: () => ({ + model: { llmConnectionSlug: 'openai', model: 'gpt-5.5' }, + thinkingLevel: 'high', + }), + settleSessionModelSettings: async () => { + hasModelOverlay = true; + return true; + }, + }); + + assert.equal(await actions.send('hello'), true); + assert.deepEqual(readinessTargets, ['gpt-5.5']); + } finally { + restoreWindow(); + } + }); + it('re-settles when model settings change during readiness', async () => { let model = 'gpt-5.5'; let settleCalls = 0; @@ -430,7 +525,7 @@ describe('busy-raced send settlement', () => { const actions = createAppShellChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, - hasSessionModelIntent: () => true, + hasSessionModelOverride: () => true, checkTaskSubmissionReadiness: async (target) => { readinessTargets.push(target?.model ?? 'missing'); if (readinessTargets.length === 1) model = 'gpt-5.6-sol'; diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 85e067e215..b4c8d12bf4 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -107,7 +107,7 @@ function createActionsDeps() { setInteractionBySession: () => undefined, preSendSettingsRef: { current: new Map() }, sendCancellationEpochRef: { current: new Map() }, - hasSessionModelIntent: () => false, + hasSessionModelOverride: () => false, readSessionModelSettings: () => undefined, settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, diff --git a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts index 78ed1e2422..e1fdbebd13 100644 --- a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts +++ b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts @@ -567,14 +567,14 @@ test('ignores a stale thinking success from the model replaced by another client }); }); -test('fences a mutation response when the catalog changed before passive reconciliation', async () => { +test('keeps a successful mutation when an older catalog snapshot commits first', async () => { const firstThinkingWrite = deferred(); const thinkingWrites: Array = []; const harness = await mountIntent({ setThinkingLevel: async (_sessionId, level) => { thinkingWrites.push(level); if (thinkingWrites.length === 1) return firstThinkingWrite.promise; - return session({ thinkingLevel: level }); + throw new Error('a successful mutation must not be repeated'); }, refreshCatalog: async () => { throw new Error('catalog refresh unavailable'); @@ -594,7 +594,8 @@ test('fences a mutation response when the catalog changed before passive reconci assert.equal(await harness.controller().settle('session-a'), true); }); - assert.deepEqual(thinkingWrites, ['high', 'high']); + assert.deepEqual(thinkingWrites, ['high']); + assert.deepEqual(harness.thinkingErrors, []); assert.deepEqual(harness.controller().overlayBySession['session-a'], { model: { llmConnectionSlug: 'anthropic', model: 'claude-sonnet' }, thinkingLevel: 'high', @@ -631,6 +632,7 @@ test('adopts the authoritative model returned by a thinking mutation without ret model: { llmConnectionSlug: 'openai', model: 'gpt-5.5' }, thinkingLevel: 'high', }); + assert.equal(harness.controller().hasModelOverride('session-a'), true); }); test('rolls back and reports a terminal first model failure', async () => { diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 9dbd25d607..de095c2225 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -168,7 +168,7 @@ export function createAppShellChatActions(deps: { setInteractionBySession: InteractionQueueUpdater; preSendSettingsRef: RefBox>; sendCancellationEpochRef: RefBox>; - hasSessionModelIntent(sessionId: string): boolean; + hasSessionModelOverride(sessionId: string): boolean; readSessionModelSettings(sessionId: string): SessionModelSettings | undefined; settleSessionModelSettings(sessionId: string): Promise; onInteractionChanged?: (sessionId: string) => void; @@ -219,7 +219,7 @@ export function createAppShellChatActions(deps: { setInteractionBySession, preSendSettingsRef, sendCancellationEpochRef, - hasSessionModelIntent, + hasSessionModelOverride, readSessionModelSettings, settleSessionModelSettings, onInteractionChanged, @@ -387,6 +387,12 @@ export function createAppShellChatActions(deps: { ): Promise { const quotes = options.quotes; const initialSessionId = activeIdRef.current; + // Capture the current worker before transcript/readiness awaits can let a + // failed mutation retire its intent. The promise retains that terminal + // result even after the controller removes the failed intent. + const settingsSettlementAtSubmit = initialSessionId + ? settleSessionModelSettings(initialSessionId) + : undefined; const initialNewTaskTarget = initialSessionId ? undefined : newTaskTarget; const sendOwner = captureComposerImportOwner(); const newChatOwner = initialSessionId ? null : sendOwner; @@ -536,11 +542,15 @@ export function createAppShellChatActions(deps: { optimisticTurnId = turnId; preSendSettingsRef.current.set(sessionId, turnId); armTurnActive(sessionId, turnId); - let overrideReadinessModel = hasSessionModelIntent(sessionId); + let overrideReadinessModel = hasSessionModelOverride(sessionId); + let capturedSettingsSettlement = settingsSettlementAtSubmit; while (true) { let settingsSettled: boolean; try { - settingsSettled = await settleSessionModelSettings(sessionId); + settingsSettled = await ( + capturedSettingsSettlement ?? settleSessionModelSettings(sessionId) + ); + capturedSettingsSettlement = undefined; } catch (error) { finishPendingSend(sessionId, turnId); throw error; @@ -551,6 +561,7 @@ export function createAppShellChatActions(deps: { disarmTurnActive(sessionId, turnId); return false; } + overrideReadinessModel ||= hasSessionModelOverride(sessionId); const settingsBeforeReadiness = readSessionModelSettings(sessionId); const submissionReady = await checkTaskSubmissionReadiness( overrideReadinessModel ? settingsBeforeReadiness?.model : undefined, @@ -568,6 +579,7 @@ export function createAppShellChatActions(deps: { disarmTurnActive(sessionId, turnId); return false; } + overrideReadinessModel ||= hasSessionModelOverride(sessionId); const settingsAfterReadiness = readSessionModelSettings(sessionId); if (!sameSessionModelSettings(settingsBeforeReadiness, settingsAfterReadiness)) { if ( @@ -579,10 +591,6 @@ export function createAppShellChatActions(deps: { } continue; } - if (!overrideReadinessModel && hasSessionModelIntent(sessionId)) { - overrideReadinessModel = true; - continue; - } if (!submissionReady) { finishPendingSend(sessionId, turnId); disarmTurnActive(sessionId, turnId); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 69c645221f..1212d046a7 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1799,7 +1799,7 @@ function AppShellContent({ setInteractionBySession, preSendSettingsRef, sendCancellationEpochRef, - hasSessionModelIntent: modelSettingsIntent.hasModelIntent, + hasSessionModelOverride: modelSettingsIntent.hasModelOverride, readSessionModelSettings: modelSettingsIntent.read, settleSessionModelSettings: modelSettingsIntent.settle, onInteractionChanged: markInteractionChanged, diff --git a/apps/desktop/src/renderer/use-session-model-settings-intent.ts b/apps/desktop/src/renderer/use-session-model-settings-intent.ts index f2dca9e4c4..9fb53bc7fe 100644 --- a/apps/desktop/src/renderer/use-session-model-settings-intent.ts +++ b/apps/desktop/src/renderer/use-session-model-settings-intent.ts @@ -47,7 +47,7 @@ export interface SessionModelSettingsIntentOptions { export interface SessionModelSettingsIntentController { overlayBySession: Readonly>; - hasModelIntent(sessionId: string): boolean; + hasModelOverride(sessionId: string): boolean; read(sessionId: string): SessionModelSettings | undefined; selectModel(sessionId: string, model: SessionModelTarget): void; selectThinkingLevel(sessionId: string, level: ThinkingLevel | undefined): void; @@ -59,6 +59,7 @@ interface ModelSettingsIntent { desired: SessionModelSettings; committed: SessionModelSettings; nextVersion: number; + catalogGeneration: number; hasModelSelection: boolean; pendingModelVersion?: number; pendingThinkingVersion?: number; @@ -109,14 +110,13 @@ function hasPendingSettings(intent: ModelSettingsIntent): boolean { intent.pendingThinkingVersion !== undefined; } -function responsePredatesCatalogSettings( +function responsePredatesCatalogModel( intent: ModelSettingsIntent, response: SessionModelSettings, - catalogRevisionAtStart: number, + catalogGenerationAtStart: number, ): boolean { - return intent.committedAtCatalogRevision !== undefined && - intent.committedAtCatalogRevision > catalogRevisionAtStart && - !sameSettings(intent.committed, response); + return intent.catalogGeneration > catalogGenerationAtStart && + !sameModel(intent.committed.model, response.model); } export function projectSessionModelSettings( @@ -177,7 +177,11 @@ export function useSessionModelSettingsIntent( if (intent.inFlight) { const authoritative = optionsRef.current.readAuthoritative(sessionId); if (authoritative) { - const rebased = applyAuthoritativeSettings(intent, settingsFromSession(authoritative)); + const authoritativeSettings = settingsFromSession(authoritative); + if (!sameSettings(intent.committed, authoritativeSettings)) { + intent.catalogGeneration += 1; + } + const rebased = applyAuthoritativeSettings(intent, authoritativeSettings); intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; if (rebased) setOverlay(sessionId, intent.desired); } @@ -227,12 +231,12 @@ export function useSessionModelSettingsIntent( const attempted = desired.model; const attemptedModelVersion = intent.pendingModelVersion; const attemptedThinkingVersion = intent.pendingThinkingVersion; - const catalogRevisionAtStart = optionsRef.current.catalogRevision; + const catalogGenerationAtStart = intent.catalogGeneration; try { const result = await optionsRef.current.setModel(sessionId, attempted); if (intentsRef.current.get(sessionId) !== intent) return false; const authoritative = settingsFromSession(result); - if (responsePredatesCatalogSettings(intent, authoritative, catalogRevisionAtStart)) { + if (responsePredatesCatalogModel(intent, authoritative, catalogGenerationAtStart)) { continue; } if ( @@ -262,12 +266,12 @@ export function useSessionModelSettingsIntent( const attempted = desired.thinkingLevel; const attemptedModel = desired.model; const attemptedThinkingVersion = intent.pendingThinkingVersion; - const catalogRevisionAtStart = optionsRef.current.catalogRevision; + const catalogGenerationAtStart = intent.catalogGeneration; try { const result = await optionsRef.current.setThinkingLevel(sessionId, attempted); if (intentsRef.current.get(sessionId) !== intent) return false; const authoritative = settingsFromSession(result); - if (responsePredatesCatalogSettings(intent, authoritative, catalogRevisionAtStart)) { + if (responsePredatesCatalogModel(intent, authoritative, catalogGenerationAtStart)) { continue; } if ( @@ -351,6 +355,7 @@ export function useSessionModelSettingsIntent( committed, desired: committed, nextVersion: 0, + catalogGeneration: 0, hasModelSelection: false, }; intentsRef.current.set(sessionId, intent); @@ -390,8 +395,13 @@ export function useSessionModelSettingsIntent( const authoritative = optionsRef.current.readAuthoritative(sessionId); return authoritative ? settingsFromSession(authoritative) : undefined; }, []); - const hasModelIntent = useCallback((sessionId: string): boolean => { - return intentsRef.current.get(sessionId)?.hasModelSelection === true; + const hasModelOverride = useCallback((sessionId: string): boolean => { + const intent = intentsRef.current.get(sessionId); + if (!intent) return false; + if (intent.hasModelSelection) return true; + const authoritative = optionsRef.current.readAuthoritative(sessionId); + return !authoritative || + !sameModel(intent.desired.model, settingsFromSession(authoritative).model); }, []); const clear = useCallback((sessionId: string): void => { intentsRef.current.delete(sessionId); @@ -400,7 +410,7 @@ export function useSessionModelSettingsIntent( return { overlayBySession, - hasModelIntent, + hasModelOverride, read, selectModel, selectThinkingLevel, From 27f5ec7badb8c04bebfa2b1ee69529894b796648 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:03:35 +0800 Subject: [PATCH 08/24] refactor(desktop): drop renderer model-setting send ordering Generated-by: OpenAI Codex --- .../app-shell-busy-race-settlement.test.ts | 503 ------------------ .../app-shell-first-send-cleanup.test.ts | 5 - .../__tests__/app-shell-stop-action.test.ts | 111 ---- .../follow-up-submit-routing.test.ts | 89 ---- .../model-settings-visual-contract.test.ts | 73 +-- .../src/renderer/app-shell-chat-actions.ts | 117 +--- .../desktop/src/renderer/app-shell-effects.ts | 2 +- .../src/renderer/app-shell-stop-action.ts | 3 - apps/desktop/src/renderer/app-shell.tsx | 57 +- .../src/renderer/follow-up-submit-routing.ts | 11 - .../src/renderer/shell-surface-owner.ts | 39 -- .../renderer/use-task-submission-readiness.ts | 9 +- 12 files changed, 32 insertions(+), 987 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts delete mode 100644 apps/desktop/src/renderer/shell-surface-owner.ts diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index fb94f1175e..7b0edd27b9 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -31,7 +31,6 @@ import { describe, it } from 'node:test'; import type { StoredMessage } from '@maka/core/session'; import type { LiveTurnProjection } from '@maka/ui'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; function installWindow(maka: unknown): () => void { const target = globalThis as unknown as { window?: unknown }; @@ -81,14 +80,6 @@ function createMessageState() { }; } -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((next) => { - resolve = next; - }); - return { promise, resolve }; -} - function createActionsDeps() { return { uiLocale: 'en' as const, @@ -113,11 +104,6 @@ function createActionsDeps() { setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, - preSendSettingsRef: { current: new Map() }, - sendCancellationEpochRef: { current: new Map() }, - hasSessionModelOverride: () => false, - readSessionModelSettings: () => undefined, - settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, newChatModel: null, @@ -133,495 +119,6 @@ function createActionsDeps() { const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; describe('busy-raced send settlement', () => { - it('presents ordinary processing while model settings settle before send', async () => { - const settlement = deferred(); - const calls: string[] = []; - let sessionSendCalls = 0; - const turnState = createTurnState(); - const restoreWindow = installWindow({ - sessions: { - send: async (_sessionId: string, command: { turnId: string }) => { - sessionSendCalls += 1; - return { - ok: true, - turnId: command.turnId, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }; - }, - }, - }); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - setLiveTurnBySession: turnState.setLiveTurnBySession, - settleSessionModelSettings: async () => { - calls.push('settle'); - return settlement.promise; - }, - }); - - const sending = actions.send('hello'); - await Promise.resolve(); - await Promise.resolve(); - assert.equal(turnState.liveTurnBySession['session-a']?.phase, 'waiting'); - assert.deepEqual(calls, ['settle']); - assert.equal(sessionSendCalls, 0); - - settlement.resolve(true); - assert.equal(await sending, true); - assert.equal(sessionSendCalls, 1); - } finally { - restoreWindow(); - } - }); - - it('remembers a settings failure that settles while a sparse transcript loads', async () => { - const mutation = deferred(); - const latest = deferred(); - let mutationStillPending = true; - let sessionSendCalls = 0; - const order: string[] = []; - const transcript = { - store: { - range: () => ({ sessionId: 'session-a', hasNewer: true }), - snapshot: () => ({ messages: [] }), - }, - async loadLatest() { - order.push('latest'); - await latest.promise; - }, - } as unknown as DesktopTranscriptRangeController; - const restoreWindow = installWindow({ - sessions: { - send: async () => { - sessionSendCalls += 1; - return { - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }; - }, - }, - }); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - transcriptRangeRef: { current: transcript }, - settleSessionModelSettings: () => { - order.push('settle'); - return mutationStillPending ? mutation.promise : Promise.resolve(true); - }, - }); - - const sending = actions.send('hello'); - await Promise.resolve(); - assert.deepEqual(order, ['settle', 'latest']); - - mutationStillPending = false; - mutation.resolve(false); - latest.resolve(); - - assert.equal(await sending, false); - assert.equal(sessionSendCalls, 0); - } finally { - restoreWindow(); - } - }); - - it('cancels a pre-send settings wait before it reaches Runtime Host', async () => { - const settlement = deferred(); - let readinessCalls = 0; - let sessionSendCalls = 0; - const turnState = createTurnState(); - const restoreWindow = installWindow({ - sessions: { - send: async () => { - sessionSendCalls += 1; - return { - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }; - }, - }, - }); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - checkTaskSubmissionReadiness: async () => { - readinessCalls += 1; - return true; - }, - setLiveTurnBySession: turnState.setLiveTurnBySession, - settleSessionModelSettings: () => settlement.promise, - }); - const cancellable = actions as typeof actions & { - cancelPendingSend(sessionId: string): boolean; - }; - - const sending = actions.send('hello'); - await Promise.resolve(); - await Promise.resolve(); - assert.equal(turnState.liveTurnBySession['session-a']?.phase, 'waiting'); - assert.equal(typeof cancellable.cancelPendingSend, 'function'); - assert.equal(cancellable.cancelPendingSend('session-a'), true); - - settlement.resolve(true); - assert.equal(await sending, false); - assert.equal(readinessCalls, 0); - assert.equal(sessionSendCalls, 0); - assert.equal(turnState.liveTurnBySession['session-a'], undefined); - } finally { - restoreWindow(); - } - }); - - it('cancels during readiness without starting another settings settlement', async () => { - const readiness = deferred(); - let settleCalls = 0; - let sessionSendCalls = 0; - const restoreWindow = installWindow({ - sessions: { - send: async () => { - sessionSendCalls += 1; - return { - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }; - }, - }, - }); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - checkTaskSubmissionReadiness: () => readiness.promise, - settleSessionModelSettings: async () => { - settleCalls += 1; - return true; - }, - }); - - const sending = actions.send('hello'); - await Promise.resolve(); - await Promise.resolve(); - assert.equal(actions.cancelPendingSend('session-a'), true); - readiness.resolve(true); - - assert.equal(await sending, false); - assert.equal(settleCalls, 1); - assert.equal(sessionSendCalls, 0); - } finally { - restoreWindow(); - } - }); - - it('does not dispatch after the composer surface owner changes during settlement', async () => { - const settlement = deferred(); - let ownsSurface = true; - let sessionSendCalls = 0; - const turnState = createTurnState(); - const restoreWindow = installWindow({ - sessions: { - send: async () => { - sessionSendCalls += 1; - return { - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }; - }, - }, - }); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - isShellSurfaceOwnerActive: () => ownsSurface, - setLiveTurnBySession: turnState.setLiveTurnBySession, - settleSessionModelSettings: () => settlement.promise, - }); - - const sending = actions.send('hello'); - await Promise.resolve(); - await Promise.resolve(); - ownsSurface = false; - settlement.resolve(true); - - assert.equal(await sending, false); - assert.equal(sessionSendCalls, 0); - assert.equal(turnState.liveTurnBySession['session-a'], undefined); - } finally { - restoreWindow(); - } - }); - - it('checks readiness against the settled model before dispatching', async () => { - const settlement = deferred(); - const calls: string[] = []; - let sessionSendCalls = 0; - const restoreWindow = installWindow({ - sessions: { - send: async () => { - sessionSendCalls += 1; - return { - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }; - }, - }, - }); - try { - const deps = { - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - hasSessionModelOverride: () => true, - checkTaskSubmissionReadiness: async (model?: { - llmConnectionSlug: string; - model: string; - }) => { - calls.push(`readiness:${model?.model ?? 'missing'}`); - return true; - }, - readSessionModelSettings: () => ({ - model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, - thinkingLevel: 'high' as const, - }), - settleSessionModelSettings: async () => { - calls.push('settle:start'); - const settled = await settlement.promise; - calls.push('settle:end'); - return settled; - }, - }; - const actions = createAppShellChatActions(deps); - - const sending = actions.send('hello'); - await Promise.resolve(); - await Promise.resolve(); - settlement.resolve(true); - - assert.equal(await sending, true); - assert.deepEqual(calls, [ - 'settle:start', - 'settle:end', - 'readiness:gpt-5.6-sol', - 'settle:start', - 'settle:end', - ]); - assert.equal(sessionSendCalls, 1); - } finally { - restoreWindow(); - } - }); - - it('preserves the shell readiness target when no local model intent exists', async () => { - const readinessTargets: Array = []; - const restoreWindow = installWindow({ - sessions: { - send: async () => ({ - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }), - }, - }); - try { - const deps = { - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - checkTaskSubmissionReadiness: async (target?: { - llmConnectionSlug: string; - model: string; - }) => { - readinessTargets.push(target?.model); - return true; - }, - hasSessionModelOverride: () => false, - readSessionModelSettings: () => ({ - model: { llmConnectionSlug: 'legacy', model: 'obsolete' }, - thinkingLevel: undefined, - }), - }; - - assert.equal(await createAppShellChatActions(deps).send('hello'), true); - assert.deepEqual(readinessTargets, [undefined]); - } finally { - restoreWindow(); - } - }); - - it('checks readiness against a model adopted from a thinking mutation', async () => { - let hasModelOverlay = false; - const readinessTargets: Array = []; - const restoreWindow = installWindow({ - sessions: { - send: async () => ({ - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }), - }, - }); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - hasSessionModelOverride: () => hasModelOverlay, - checkTaskSubmissionReadiness: async (target) => { - readinessTargets.push(target?.model); - return true; - }, - readSessionModelSettings: () => ({ - model: { llmConnectionSlug: 'openai', model: 'gpt-5.5' }, - thinkingLevel: 'high', - }), - settleSessionModelSettings: async () => { - hasModelOverlay = true; - return true; - }, - }); - - assert.equal(await actions.send('hello'), true); - assert.deepEqual(readinessTargets, ['gpt-5.5']); - } finally { - restoreWindow(); - } - }); - - it('re-settles when model settings change during readiness', async () => { - let model = 'gpt-5.5'; - let settleCalls = 0; - const readinessTargets: string[] = []; - let sessionSendCalls = 0; - const restoreWindow = installWindow({ - sessions: { - send: async () => { - sessionSendCalls += 1; - return { - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }; - }, - }, - }); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - hasSessionModelOverride: () => true, - checkTaskSubmissionReadiness: async (target) => { - readinessTargets.push(target?.model ?? 'missing'); - if (readinessTargets.length === 1) model = 'gpt-5.6-sol'; - return true; - }, - readSessionModelSettings: () => ({ - model: { llmConnectionSlug: 'openai', model }, - thinkingLevel: undefined, - }), - settleSessionModelSettings: async () => { - settleCalls += 1; - return true; - }, - }); - - assert.equal(await actions.send('hello'), true); - assert.ok(settleCalls >= 2); - assert.deepEqual(readinessTargets, ['gpt-5.5', 'gpt-5.6-sol']); - assert.equal(sessionSendCalls, 1); - } finally { - restoreWindow(); - } - }); - - it('does not dispatch when the chat surface becomes obscured during readiness', async () => { - let ownsSurface = true; - let sessionSendCalls = 0; - const turnState = createTurnState(); - const restoreWindow = installWindow({ - sessions: { - send: async () => { - sessionSendCalls += 1; - return { - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }; - }, - }, - }); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - checkTaskSubmissionReadiness: async () => { - ownsSurface = false; - return true; - }, - isShellSurfaceOwnerActive: () => ownsSurface, - setLiveTurnBySession: turnState.setLiveTurnBySession, - }); - - assert.equal(await actions.send('hello'), false); - assert.equal(sessionSendCalls, 0); - assert.equal(turnState.liveTurnBySession['session-a'], undefined); - } finally { - restoreWindow(); - } - }); - - it('disarms processing and skips send when model settings fail to settle', async () => { - let sessionSendCalls = 0; - const turnState = createTurnState(); - const restoreWindow = installWindow({ - sessions: { - send: async () => { - sessionSendCalls += 1; - return { - ok: true, - attachments: [], - inlineReferences: [], - skillInvocation: EMPTY_SKILL_INVOCATION, - }; - }, - }, - }); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - setLiveTurnBySession: turnState.setLiveTurnBySession, - settleSessionModelSettings: async () => { - assert.equal(turnState.liveTurnBySession['session-a']?.phase, 'waiting'); - return false; - }, - }); - - assert.equal(await actions.send('hello'), false); - assert.equal(turnState.liveTurnBySession['session-a'], undefined); - assert.equal(sessionSendCalls, 0); - } finally { - restoreWindow(); - } - }); - it('a steered send on an existing session disarms its turn and shows no optimistic message', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index b4c8d12bf4..967d777dc9 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -105,11 +105,6 @@ function createActionsDeps() { setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, - preSendSettingsRef: { current: new Map() }, - sendCancellationEpochRef: { current: new Map() }, - hasSessionModelOverride: () => false, - readSessionModelSettings: () => undefined, - settleSessionModelSettings: async () => true, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, newChatModel: null, diff --git a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts deleted file mode 100644 index 9360557562..0000000000 --- a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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 { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; - -import { createAppShellStopAction } from '../../renderer/app-shell-stop-action.js'; - -function installWindow(maka: unknown): () => void { - const target = globalThis as unknown as { window?: unknown }; - const hadWindow = Object.prototype.hasOwnProperty.call(target, 'window'); - const previousWindow = target.window; - Object.defineProperty(target, 'window', { - configurable: true, - value: { maka }, - writable: true, - }); - return () => { - if (hadWindow) { - Object.defineProperty(target, 'window', { - configurable: true, - value: previousWindow, - writable: true, - }); - } else { - delete target.window; - } - }; -} - -describe('app shell stop action', () => { - it('cancels a local pre-send flight and still forwards Stop to Runtime Host', async () => { - let localCancellations = 0; - let hostStops = 0; - const restoreWindow = installWindow({ - sessions: { - stop: async () => { - hostStops += 1; - }, - }, - }); - try { - const deps = { - uiLocale: 'en' as const, - activeIdRef: { current: 'session-a' as string | undefined }, - addPendingSessionAction: () => true, - clearPendingSessionAction: () => undefined, - setStopPendingBySession: () => undefined, - stopPendingRef: { current: new Set() }, - toastApi: { error: () => undefined }, - cancelPendingSend: (sessionId: string) => { - assert.equal(sessionId, 'session-a'); - localCancellations += 1; - return true; - }, - }; - - await createAppShellStopAction(deps)(); - - assert.equal(localCancellations, 1); - assert.equal(hostStops, 1); - } finally { - restoreWindow(); - } - }); - - it('stops the Runtime Host turn when no local pre-send flight exists', async () => { - let hostStops = 0; - const restoreWindow = installWindow({ - sessions: { - stop: async (sessionId: string, input: unknown) => { - assert.equal(sessionId, 'session-a'); - assert.deepEqual(input, { source: 'stop_button' }); - hostStops += 1; - }, - }, - }); - try { - await createAppShellStopAction({ - uiLocale: 'en', - activeIdRef: { current: 'session-a' }, - addPendingSessionAction: () => true, - clearPendingSessionAction: () => undefined, - setStopPendingBySession: () => undefined, - stopPendingRef: { current: new Set() }, - cancelPendingSend: () => false, - toastApi: { error: () => undefined }, - })(); - - assert.equal(hostStops, 1); - } finally { - restoreWindow(); - } - }); -}); diff --git a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts index 7ab48acd26..b829b09021 100644 --- a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts +++ b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts @@ -23,17 +23,8 @@ import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, resolveFollowUpModeAtSubmit, - submitFollowUpAfterModelSettings, } from '../../renderer/follow-up-submit-routing.js'; -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((next) => { - resolve = next; - }); - return { promise, resolve }; -} - describe('follow-up submit routing', () => { it('uses the synchronous turn arm before React publishes streaming state', () => { assert.equal( @@ -80,86 +71,6 @@ describe('follow-up submit routing', () => { ); }); - it('waits for model settings before enqueueing a follow-up', async () => { - const settlement = deferred(); - let enqueueCalls = 0; - const submitting = submitFollowUpAfterModelSettings({ - sessionId: 'session-a', - settleSessionModelSettings: () => settlement.promise, - isSubmissionCurrent: () => true, - enqueue: async () => { - enqueueCalls += 1; - return true; - }, - }); - - await Promise.resolve(); - assert.equal(enqueueCalls, 0); - settlement.resolve(true); - assert.equal(await submitting, true); - assert.equal(enqueueCalls, 1); - }); - - it('does not enqueue a follow-up when model settings fail to settle', async () => { - let enqueueCalls = 0; - assert.equal( - await submitFollowUpAfterModelSettings({ - sessionId: 'session-a', - settleSessionModelSettings: async () => false, - isSubmissionCurrent: () => true, - enqueue: async () => { - enqueueCalls += 1; - return true; - }, - }), - false, - ); - assert.equal(enqueueCalls, 0); - }); - - it('does not enqueue after Stop cancels the follow-up waiter', async () => { - const settlement = deferred(); - let cancellationEpoch = 0; - let enqueueCalls = 0; - const capturedEpoch = cancellationEpoch; - const submitting = submitFollowUpAfterModelSettings({ - sessionId: 'session-a', - settleSessionModelSettings: () => settlement.promise, - isSubmissionCurrent: () => cancellationEpoch === capturedEpoch, - enqueue: async () => { - enqueueCalls += 1; - return true; - }, - }); - - cancellationEpoch += 1; - settlement.resolve(true); - - assert.equal(await submitting, false); - assert.equal(enqueueCalls, 0); - }); - - it('does not enqueue after the composer surface owner changes', async () => { - const settlement = deferred(); - let ownsSurface = true; - let enqueueCalls = 0; - const submitting = submitFollowUpAfterModelSettings({ - sessionId: 'session-a', - settleSessionModelSettings: () => settlement.promise, - isSubmissionCurrent: () => ownsSurface, - enqueue: async () => { - enqueueCalls += 1; - return true; - }, - }); - - ownsSurface = false; - settlement.resolve(true); - - assert.equal(await submitting, false); - assert.equal(enqueueCalls, 0); - }); - it('restores workspace references after queued text returns to the draft', () => { assert.deepEqual( mergeWorkspaceReferences( diff --git a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts index 1d4ee9f226..8cd7886bf6 100644 --- a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts @@ -20,7 +20,6 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import { test } from 'node:test'; -import { isShellSurfaceOwnerCurrent } from '../../renderer/shell-surface-owner.js'; function readFirst(candidates: URL[]): string { const sourceUrl = candidates.find((candidate) => existsSync(candidate)); @@ -41,9 +40,17 @@ const appShellSource = readFirst([ new URL('../../renderer/app-shell.tsx', import.meta.url), new URL('../../../src/renderer/app-shell.tsx', import.meta.url), ]); -const appShellEffectsSource = readFirst([ - new URL('../../renderer/app-shell-effects.ts', import.meta.url), - new URL('../../../src/renderer/app-shell-effects.ts', import.meta.url), +const chatActionsSource = readFirst([ + new URL('../../renderer/app-shell-chat-actions.ts', import.meta.url), + new URL('../../../src/renderer/app-shell-chat-actions.ts', import.meta.url), +]); +const stopActionSource = readFirst([ + new URL('../../renderer/app-shell-stop-action.ts', import.meta.url), + new URL('../../../src/renderer/app-shell-stop-action.ts', import.meta.url), +]); +const followUpSource = readFirst([ + new URL('../../renderer/follow-up-submit-routing.ts', import.meta.url), + new URL('../../../src/renderer/follow-up-submit-routing.ts', import.meta.url), ]); const modelSettingsIntentSource = readFirst([ new URL('../../renderer/use-session-model-settings-intent.ts', import.meta.url), @@ -65,21 +72,12 @@ test('model setting mutations never own spinner or disabled presentation', () => assert.match(composerSource, /disabled=\{Boolean\(modelSwitcherDisabledReason\)\}/); }); -test('AppShell projects optimistic settings and gates send on their settlement', () => { - assert.match(appShellSource, /useSessionModelSettingsIntent\(\{/); - assert.match(appShellSource, /projectSessionModelSettings\(/); - assert.match( - appShellSource, - /settleSessionModelSettings:\s*modelSettingsIntent\.settle/, - ); - assert.match(appShellSource, /submitFollowUpAfterModelSettings\(\{/); - assert.match(appShellSource, /isSubmissionCurrent:\s*\(\) =>/); - assert.match(appShellSource, /sendCancellationEpochRef\.current\.get\(sessionId\)/); - assert.match(appShellSource, /modelSettingsIntent\.selectModel\(activeId, input\)/); - assert.match( - appShellSource, - /modelSettingsIntent\.selectThinkingLevel\(activeId, level\)/, - ); +test('model setting intent never orders renderer send paths', () => { + assert.doesNotMatch(appShellSource, /settleSessionModelSettings/); + assert.doesNotMatch(appShellSource, /sendCancellationEpochRef/); + assert.doesNotMatch(chatActionsSource, /preSendSettingsRef|cancelPendingSend/); + assert.doesNotMatch(stopActionSource, /cancelPendingSend/); + assert.doesNotMatch(followUpSource, /submitFollowUpAfterModelSettings/); }); test('model setting failures stay on the chat surface that owns the session', () => { @@ -89,43 +87,6 @@ test('model setting failures stay on the chat surface that owns the session', () ); const surfaceGuards = intentOptions.match(/isComposerImportOwnerActive\(\{/g); assert.equal(surfaceGuards?.length, 2); - assert.match(appShellSource, /return isShellSurfaceOwnerCurrent\(\{/); -}); - -test('session setting error surface rejects every obscured or replaced chat state', () => { - const visible = { - owner: { sessionId: 'session-a', navSection: 'sessions' }, - currentSessionId: 'session-a', - currentNavSection: 'sessions', - currentNewTaskDraftKey: 'draft-a', - shellObscured: false, - workHubActive: false, - }; - assert.equal(isShellSurfaceOwnerCurrent(visible), true); - assert.equal(isShellSurfaceOwnerCurrent({ ...visible, shellObscured: true }), false); - assert.equal(isShellSurfaceOwnerCurrent({ ...visible, workHubActive: true }), false); - assert.equal(isShellSurfaceOwnerCurrent({ - ...visible, - currentNavSection: 'modules', - }), false); - assert.equal(isShellSurfaceOwnerCurrent({ - ...visible, - currentSessionId: 'session-b', - }), false); -}); - -test('navigation ownership is published before async results can resume', () => { - assert.match( - appShellEffectsSource, - /useAppShellNavRefSync[\s\S]*?useLayoutEffect\(\(\) => \{\s*options\.navSelectionRef\.current/, - ); -}); - -test('settled model readiness does not ignore model-target blockers', () => { - assert.match( - appShellSource, - /ignoreModelTarget:\s*model\s*\?\s*false\s*:\s*ignoreTaskReadinessModelTarget/, - ); }); test('optimistic settings stay inside committed model-control state', () => { diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index de095c2225..3c74ddfd47 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -88,21 +88,6 @@ type PendingNewChatModel = { model: string; } | null; -type SessionModelSettings = { - model: NonNullable; - thinkingLevel: ThinkingLevel | undefined; -}; - -function sameSessionModelSettings( - left: SessionModelSettings | undefined, - right: SessionModelSettings | undefined, -): boolean { - if (!left || !right) return left === right; - return left.model.llmConnectionSlug === right.model.llmConnectionSlug && - left.model.model === right.model.model && - left.thinkingLevel === right.thinkingLevel; -} - type PendingNewChatThinkingLevel = ThinkingLevel | null; type ToastApi = { @@ -127,7 +112,6 @@ export interface AppShellChatActions { onSessionResolved?: (sessionId: string) => void; }, ): Promise; - cancelPendingSend(sessionId: string): boolean; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; @@ -143,7 +127,7 @@ export function createAppShellChatActions(deps: { setPendingBySession: BooleanRecordUpdater, ) => boolean; captureComposerImportOwner: () => ComposerImportOwner; - checkTaskSubmissionReadiness: (model?: NonNullable) => Promise; + checkTaskSubmissionReadiness: () => Promise; clearPendingSessionAction: ( sessionId: string, pendingRef: RefBox>, @@ -166,11 +150,6 @@ export function createAppShellChatActions(deps: { * window opens before any SessionEvent arrives (turn_started is not one). */ setLiveTurnBySession: LiveTurnRecordUpdater; setInteractionBySession: InteractionQueueUpdater; - preSendSettingsRef: RefBox>; - sendCancellationEpochRef: RefBox>; - hasSessionModelOverride(sessionId: string): boolean; - readSessionModelSettings(sessionId: string): SessionModelSettings | undefined; - settleSessionModelSettings(sessionId: string): Promise; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; @@ -217,11 +196,6 @@ export function createAppShellChatActions(deps: { setNavSelection, setLiveTurnBySession, setInteractionBySession, - preSendSettingsRef, - sendCancellationEpochRef, - hasSessionModelOverride, - readSessionModelSettings, - settleSessionModelSettings, onInteractionChanged, onExecutionBoundaryChanged, showModelSetupToast, @@ -322,24 +296,6 @@ export function createAppShellChatActions(deps: { }); } - function finishPendingSend(sessionId: string, turnId: string): boolean { - if (preSendSettingsRef.current.get(sessionId) !== turnId) return false; - preSendSettingsRef.current.delete(sessionId); - return true; - } - - function cancelPendingSend(sessionId: string): boolean { - sendCancellationEpochRef.current.set( - sessionId, - (sendCancellationEpochRef.current.get(sessionId) ?? 0) + 1, - ); - const turnId = preSendSettingsRef.current.get(sessionId); - if (turnId === undefined) return false; - preSendSettingsRef.current.delete(sessionId); - disarmTurnActive(sessionId, turnId); - return true; - } - // Rename only the exact unconfirmed arm this send created. Host events can // beat the IPC response (main emits the sessions-changed nudge before it // returns), and an authoritative projection that already arrived for the @@ -387,17 +343,11 @@ export function createAppShellChatActions(deps: { ): Promise { const quotes = options.quotes; const initialSessionId = activeIdRef.current; - // Capture the current worker before transcript/readiness awaits can let a - // failed mutation retire its intent. The promise retains that terminal - // result even after the controller removes the failed intent. - const settingsSettlementAtSubmit = initialSessionId - ? settleSessionModelSettings(initialSessionId) - : undefined; const initialNewTaskTarget = initialSessionId ? undefined : newTaskTarget; const sendOwner = captureComposerImportOwner(); const newChatOwner = initialSessionId ? null : sendOwner; if (!initialSessionId && !initialNewTaskTarget) return false; - if (!initialSessionId && !(await checkTaskSubmissionReadiness())) return false; + if (!(await checkTaskSubmissionReadiness())) return false; if ( (initialSessionId && !isShellSurfaceOwnerActive(sendOwner)) || (newChatOwner && !isNewChatSendSurfaceActive(newChatOwner)) @@ -540,69 +490,7 @@ export function createAppShellChatActions(deps: { } optimisticSessionId = sessionId; optimisticTurnId = turnId; - preSendSettingsRef.current.set(sessionId, turnId); armTurnActive(sessionId, turnId); - let overrideReadinessModel = hasSessionModelOverride(sessionId); - let capturedSettingsSettlement = settingsSettlementAtSubmit; - while (true) { - let settingsSettled: boolean; - try { - settingsSettled = await ( - capturedSettingsSettlement ?? settleSessionModelSettings(sessionId) - ); - capturedSettingsSettlement = undefined; - } catch (error) { - finishPendingSend(sessionId, turnId); - throw error; - } - if (preSendSettingsRef.current.get(sessionId) !== turnId) return false; - if (!settingsSettled || !isShellSurfaceOwnerActive(sendOwner)) { - finishPendingSend(sessionId, turnId); - disarmTurnActive(sessionId, turnId); - return false; - } - overrideReadinessModel ||= hasSessionModelOverride(sessionId); - const settingsBeforeReadiness = readSessionModelSettings(sessionId); - const submissionReady = await checkTaskSubmissionReadiness( - overrideReadinessModel ? settingsBeforeReadiness?.model : undefined, - ); - if (preSendSettingsRef.current.get(sessionId) !== turnId) return false; - try { - settingsSettled = await settleSessionModelSettings(sessionId); - } catch (error) { - finishPendingSend(sessionId, turnId); - throw error; - } - if (preSendSettingsRef.current.get(sessionId) !== turnId) return false; - if (!settingsSettled || !isShellSurfaceOwnerActive(sendOwner)) { - finishPendingSend(sessionId, turnId); - disarmTurnActive(sessionId, turnId); - return false; - } - overrideReadinessModel ||= hasSessionModelOverride(sessionId); - const settingsAfterReadiness = readSessionModelSettings(sessionId); - if (!sameSessionModelSettings(settingsBeforeReadiness, settingsAfterReadiness)) { - if ( - settingsBeforeReadiness?.model.llmConnectionSlug !== - settingsAfterReadiness?.model.llmConnectionSlug || - settingsBeforeReadiness?.model.model !== settingsAfterReadiness?.model.model - ) { - overrideReadinessModel = true; - } - continue; - } - if (!submissionReady) { - finishPendingSend(sessionId, turnId); - disarmTurnActive(sessionId, turnId); - return false; - } - break; - } - if (!finishPendingSend(sessionId, turnId)) return false; - if (!isShellSurfaceOwnerActive(sendOwner)) { - disarmTurnActive(sessionId, turnId); - return false; - } const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -835,7 +723,6 @@ export function createAppShellChatActions(deps: { return { send, - cancelPendingSend, respondToSandboxBoundary, respondToUserQuestion, refreshMessages, diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 79da7df68c..588225d4f4 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -84,7 +84,7 @@ type ToastApi = { }; export function useAppShellNavRefSync(options: { navSelection: NavSelection; navSelectionRef: RefBox }) { - useLayoutEffect(() => { + useEffect(() => { options.navSelectionRef.current = options.navSelection; }, [options.navSelection]); } diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index c5b4c1cb89..81b8c34dc7 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -48,7 +48,6 @@ export function createAppShellStopAction(deps: { ) => void; setStopPendingBySession: BooleanRecordUpdater; stopPendingRef: RefBox>; - cancelPendingSend(sessionId: string): boolean; toastApi: ToastApi; }): () => Promise { const { @@ -58,7 +57,6 @@ export function createAppShellStopAction(deps: { clearPendingSessionAction, setStopPendingBySession, stopPendingRef, - cancelPendingSend, toastApi, } = deps; @@ -66,7 +64,6 @@ export function createAppShellStopAction(deps: { const sessionId = activeIdRef.current; if (!sessionId || !addPendingSessionAction(sessionId, stopPendingRef, setStopPendingBySession)) return; try { - cancelPendingSend(sessionId); await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); } catch (error) { // The Composer wires this through both the Stop button onClick diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 1212d046a7..f46d34bf4c 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -102,7 +102,6 @@ import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, resolveFollowUpModeAtSubmit, - submitFollowUpAfterModelSettings, } from './follow-up-submit-routing'; import { PlanExecutionPanel, @@ -132,10 +131,8 @@ import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { useShellSearch } from './use-shell-search'; import { useSessionSettingIntent } from './use-session-setting-intent'; -import { isShellSurfaceOwnerCurrent } from './shell-surface-owner'; import { projectSessionModelSettings, - type SessionModelTarget, useSessionModelSettingsIntent, } from './use-session-model-settings-intent'; import { deriveStaleSessionIds } from './stale-sessions'; @@ -1663,10 +1660,6 @@ function AppShellContent({ const hasModalOpen = helpOpen || paletteOpen || searchModalOpen; const shellObscured = hasModalOpen || settingsOpen; - const shellSurfaceStateRef = useRef({ shellObscured, workHubActive }); - useLayoutEffect(() => { - shellSurfaceStateRef.current = { shellObscured, workHubActive }; - }, [shellObscured, workHubActive]); const contextCompactionPresentation = useMemo( () => createContextCompactionPresentation({ @@ -1769,11 +1762,8 @@ function AppShellContent({ setUiLocaleOverride, }); - const preSendSettingsRef = useRef(new Map()); - const sendCancellationEpochRef = useRef(new Map()); const { send, - cancelPendingSend, respondToSandboxBoundary, respondToUserQuestion, refreshMessages, @@ -1797,11 +1787,6 @@ function AppShellContent({ setNavSelection, setLiveTurnBySession, setInteractionBySession, - preSendSettingsRef, - sendCancellationEpochRef, - hasSessionModelOverride: modelSettingsIntent.hasModelOverride, - readSessionModelSettings: modelSettingsIntent.read, - settleSessionModelSettings: modelSettingsIntent.settle, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, showModelSetupToast, @@ -1857,20 +1842,10 @@ function AppShellContent({ toastApi, }); - async function taskSubmissionReadyAtSend( - model?: SessionModelTarget, - ): Promise { - const snapshot = await taskReadiness.checkNow( - model - ? { - ...taskReadinessRequest, - connectionSlug: model.llmConnectionSlug, - model: model.model, - } - : undefined, - ); + async function taskSubmissionReadyAtSend(): Promise { + const snapshot = await taskReadiness.checkNow(); return !isTaskSubmissionHardBlocked(snapshot, { - ignoreModelTarget: model ? false : ignoreTaskReadinessModelTarget, + ignoreModelTarget: ignoreTaskReadinessModelTarget, }); } @@ -1968,18 +1943,9 @@ function AppShellContent({ }) : undefined; if (sessionId && followUpAtSubmit) { - const followUpOwner = captureComposerImportOwner(); - const cancellationEpoch = sendCancellationEpochRef.current.get(sessionId) ?? 0; - const queued = await submitFollowUpAfterModelSettings({ - sessionId, - settleSessionModelSettings: modelSettingsIntent.settle, - isSubmissionCurrent: () => - isShellSurfaceOwnerActive(followUpOwner) && - (sendCancellationEpochRef.current.get(sessionId) ?? 0) === cancellationEpoch, - enqueue: () => enqueueFollowUp(sessionId, text, followUpAtSubmit, { - ...metadata, - workspaceFileReferences, - }), + const queued = await enqueueFollowUp(sessionId, text, followUpAtSubmit, { + ...metadata, + workspaceFileReferences, }); if (queued) delete retractedWorkspaceReferencesRef.current[sessionId]; return queued; @@ -2227,7 +2193,6 @@ function AppShellContent({ clearPendingSessionAction, setStopPendingBySession, stopPendingRef, - cancelPendingSend, toastApi, }); @@ -2463,13 +2428,9 @@ function AppShellContent({ * send pull a user out of 技能 and into 设置 · 模型. */ function isShellSurfaceOwnerActive(owner: ComposerImportOwner): boolean { - return isShellSurfaceOwnerCurrent({ - owner, - currentNavSection: navSelectionRef.current.section, - currentSessionId: activeIdRef.current, - currentNewTaskDraftKey, - ...shellSurfaceStateRef.current, - }); + return navSelectionRef.current.section === owner.navSection && + activeIdRef.current === owner.sessionId && + (owner.sessionId !== undefined || owner.newTaskDraftKey === currentNewTaskDraftKey); } /** …and the owner was captured on the chat surface. */ diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 25f8e82797..768477dba3 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -42,17 +42,6 @@ export function resolveFollowUpModeAtSubmit(input: { return input.hasActiveTurn ? 'queue' : undefined; } -export async function submitFollowUpAfterModelSettings(input: { - sessionId: string; - settleSessionModelSettings(sessionId: string): Promise; - isSubmissionCurrent(): boolean; - enqueue(): Promise; -}): Promise { - if (!(await input.settleSessionModelSettings(input.sessionId))) return false; - if (!input.isSubmissionCurrent()) return false; - return input.enqueue(); -} - export function mergeWorkspaceReferences( text: string, live: readonly WorkspaceFileReferencePosition[] | undefined, diff --git a/apps/desktop/src/renderer/shell-surface-owner.ts b/apps/desktop/src/renderer/shell-surface-owner.ts deleted file mode 100644 index cf8b628d33..0000000000 --- a/apps/desktop/src/renderer/shell-surface-owner.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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. - */ - -interface ShellSurfaceOwner { - sessionId: string | undefined; - navSection: string; - newTaskDraftKey?: string; -} - -export function isShellSurfaceOwnerCurrent(input: { - owner: ShellSurfaceOwner; - currentSessionId: string | undefined; - currentNavSection: string; - currentNewTaskDraftKey: string; - shellObscured: boolean; - workHubActive: boolean; -}): boolean { - if (input.shellObscured || input.workHubActive) return false; - return input.currentNavSection === input.owner.navSection && - input.currentSessionId === input.owner.sessionId && - (input.owner.sessionId !== undefined || - input.owner.newTaskDraftKey === input.currentNewTaskDraftKey); -} diff --git a/apps/desktop/src/renderer/use-task-submission-readiness.ts b/apps/desktop/src/renderer/use-task-submission-readiness.ts index 0dee1e8055..d95a5a7018 100644 --- a/apps/desktop/src/renderer/use-task-submission-readiness.ts +++ b/apps/desktop/src/renderer/use-task-submission-readiness.ts @@ -35,16 +35,13 @@ export function useTaskSubmissionReadiness( const requestSequence = useRef(0); const refresh = useCallback(() => setRevision((value) => value + 1), []); - const checkNow = useCallback(async ( - requestOverride?: DesktopTaskSubmissionReadinessRequest, - ) => { + const checkNow = useCallback(async () => { const sequence = ++requestSequence.current; - const requestAtCheck = requestOverride ?? request; try { const next = sessionId - ? await window.maka.taskReadiness.getSnapshot(requestAtCheck, sessionId) + ? await window.maka.taskReadiness.getSnapshot(request, sessionId) : newTaskTarget - ? await window.maka.newTasks.getReadiness(newTaskTarget, requestAtCheck) + ? await window.maka.newTasks.getReadiness(newTaskTarget, request) : undefined; if (requestSequence.current === sequence) setSnapshot(next); return next; From b6d6493546a53b2d213d11ac441c9ce61232b193 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:05:25 +0800 Subject: [PATCH 09/24] refactor(desktop): reuse session setting intents for model controls Generated-by: OpenAI Codex --- .../model-settings-visual-contract.test.ts | 26 +- .../session-model-settings-intent.test.ts | 914 ------------------ .../__tests__/session-setting-intent.test.ts | 208 +++- apps/desktop/src/renderer/app-shell.tsx | 76 +- .../use-session-model-settings-intent.ts | 420 -------- 5 files changed, 242 insertions(+), 1402 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts delete mode 100644 apps/desktop/src/renderer/use-session-model-settings-intent.ts diff --git a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts index 8cd7886bf6..732fa787e7 100644 --- a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts @@ -52,10 +52,6 @@ const followUpSource = readFirst([ new URL('../../renderer/follow-up-submit-routing.ts', import.meta.url), new URL('../../../src/renderer/follow-up-submit-routing.ts', import.meta.url), ]); -const modelSettingsIntentSource = readFirst([ - new URL('../../renderer/use-session-model-settings-intent.ts', import.meta.url), - new URL('../../../src/renderer/use-session-model-settings-intent.ts', import.meta.url), -]); const shellChatModelSource = readFirst([ new URL('../../renderer/use-shell-chat-model.ts', import.meta.url), new URL('../../../src/renderer/use-shell-chat-model.ts', import.meta.url), @@ -82,22 +78,28 @@ test('model setting intent never orders renderer send paths', () => { test('model setting failures stay on the chat surface that owns the session', () => { const intentOptions = appShellSource.slice( - appShellSource.indexOf('const modelSettingsIntent = useSessionModelSettingsIntent'), + appShellSource.indexOf('const modelIntent = useSessionSettingIntent'), appShellSource.indexOf('const activeSessionForModelControls'), ); - const surfaceGuards = intentOptions.match(/isComposerImportOwnerActive\(\{/g); - assert.equal(surfaceGuards?.length, 2); + const activeSessionGuards = intentOptions.match(/activeIdRef\.current !== sessionId/g); + assert.equal(activeSessionGuards?.length, 2); }); -test('optimistic settings stay inside committed model-control state', () => { +test('model and thinking settings reuse the generic intent seam', () => { assert.match( - modelSettingsIntentSource, - /useLayoutEffect\(\(\) => \{\s*optionsRef\.current = options;\s*\}\);/, + appShellSource, + /const modelIntent = useSessionSettingIntent\(\{/, ); assert.match( - modelSettingsIntentSource, - /useLayoutEffect\(\(\) => \{\s*for \(const \[sessionId, intent\] of intentsRef\.current\)/, + appShellSource, + /const thinkingLevelIntent = useSessionSettingIntent\(\{/, ); + assert.doesNotMatch(appShellSource, /useSessionModelSettingsIntent/); + assert.match(appShellSource, /modelIntent\.request\(activeId, input\)/); + assert.match(appShellSource, /thinkingLevelIntent\.request\(activeId, level \?\? null\)/); +}); + +test('optimistic settings stay inside model-control state', () => { assert.match(appShellSource, /sessionHealthSession:\s*activeSession/); assert.match( appShellSource, diff --git a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts b/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts deleted file mode 100644 index e1fdbebd13..0000000000 --- a/apps/desktop/src/main/__tests__/session-model-settings-intent.test.ts +++ /dev/null @@ -1,914 +0,0 @@ -/* - * 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 assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import type { ThinkingLevel } from '@maka/core/model-thinking'; -import type { SessionSummary } from '@maka/core/session'; -import { act, createElement, useLayoutEffect } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; -import { - type SessionModelSettingsIntentController, - type SessionModelSettingsIntentOptions, - type SessionModelTarget, - projectSessionModelSettings, - useSessionModelSettingsIntent, -} from '../../renderer/use-session-model-settings-intent.js'; - -const originalGlobals = { - document: globalThis.document, - window: globalThis.window, - HTMLElement: globalThis.HTMLElement, - Node: globalThis.Node, - IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) - .IS_REACT_ACT_ENVIRONMENT, -}; - -let mountedRoot: Root | undefined; - -afterEach(async () => { - if (mountedRoot) await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - Object.assign(globalThis, originalGlobals); -}); - -test('publishes a model selection before the Host mutation settles', async () => { - const modelWrite = deferred(); - const harness = await mountIntent({ setModel: async () => modelWrite.promise }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - }); - - assert.deepEqual(harness.controller().overlayBySession['session-a'], { - model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, - thinkingLevel: undefined, - }); -}); - -test('persists the composer default after a model commit succeeds', async () => { - const harness = await mountIntent(); - const target = { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }; - - await act(async () => { - harness.controller().selectModel('session-a', target); - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(harness.savedModels, [target]); -}); - -test('coalesces rapid thinking changes to the latest pending level', async () => { - const first = deferred(); - const writes: Array = []; - const harness = await mountIntent({ - setThinkingLevel: async (_sessionId, level) => { - writes.push(level); - if (writes.length === 1) return first.promise; - return session({ thinkingLevel: level }); - }, - }); - - await act(async () => { - harness.controller().selectThinkingLevel('session-a', 'high'); - harness.controller().selectThinkingLevel('session-a', 'xhigh'); - harness.controller().selectThinkingLevel('session-a', 'low'); - }); - first.resolve(session({ thinkingLevel: 'high' })); - - let settled = false; - await act(async () => { - settled = await harness.controller().settle('session-a'); - }); - - assert.equal(settled, true); - assert.deepEqual(writes, ['high', 'low']); - assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, 'low'); -}); - -test('writes the original thinking level after reverting an in-flight change', async () => { - const first = deferred(); - const writes: Array = []; - const harness = await mountIntent({ - setThinkingLevel: async (_sessionId, level) => { - writes.push(level); - if (writes.length === 1) return first.promise; - return session({ thinkingLevel: level }); - }, - }); - - await act(async () => { - harness.controller().selectThinkingLevel('session-a', 'high'); - harness.controller().selectThinkingLevel('session-a', undefined); - }); - first.resolve(session({ thinkingLevel: 'high' })); - - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(writes, ['high', undefined]); - assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, undefined); -}); - -test('coalesces rapid model changes to the latest pending model', async () => { - const first = deferred(); - const writes: string[] = []; - const harness = await mountIntent({ - setModel: async (_sessionId, model) => { - writes.push(model.model); - if (writes.length === 1) return first.promise; - return session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }); - }, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.5', - }); - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-terra', - }); - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - }); - first.resolve(session({ llmConnectionSlug: 'openai', model: 'gpt-5.5' })); - - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(writes, ['gpt-5.5', 'gpt-5.6-sol']); - assert.equal( - harness.controller().overlayBySession['session-a']?.model.model, - 'gpt-5.6-sol', - ); -}); - -test('preserves a reverted model when catalog refresh observes the in-flight change', async () => { - const first = deferred(); - const writes: string[] = []; - const harness = await mountIntent({ - setModel: async (_sessionId, model) => { - writes.push(model.model); - if (writes.length === 1) return first.promise; - return session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }); - }, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'anthropic', - model: 'claude-sonnet', - }); - }); - await harness.render(1, session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - thinkingLevel: undefined, - })); - first.resolve(session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - thinkingLevel: undefined, - })); - - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(writes, ['gpt-5.6-sol', 'claude-sonnet']); - assert.deepEqual(harness.controller().overlayBySession['session-a']?.model, { - llmConnectionSlug: 'anthropic', - model: 'claude-sonnet', - }); -}); - -test('continues with a selection made while the current worker is settling', async () => { - const writes: string[] = []; - let refreshes = 0; - let harness!: Awaited>; - harness = await mountIntent({ - setModel: async (_sessionId, model) => { - writes.push(model.model); - return session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }); - }, - refreshCatalog: async () => { - refreshes += 1; - if (refreshes !== 1) return; - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - }, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.5', - }); - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(writes, ['gpt-5.5', 'gpt-5.6-sol']); - assert.equal( - harness.controller().overlayBySession['session-a']?.model.model, - 'gpt-5.6-sol', - ); -}); - -test('reports failure when a settling-time continuation cannot commit', async () => { - const writes: string[] = []; - let refreshes = 0; - let harness!: Awaited>; - harness = await mountIntent({ - setModel: async (_sessionId, model) => { - writes.push(model.model); - if (writes.length === 2) throw new Error('latest model unavailable'); - return session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }); - }, - refreshCatalog: async () => { - refreshes += 1; - if (refreshes !== 1) return; - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - }, - }); - - let settled = true; - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.5', - }); - settled = await harness.controller().settle('session-a'); - }); - - assert.deepEqual(writes, ['gpt-5.5', 'gpt-5.6-sol']); - assert.equal(settled, false); - assert.equal(harness.modelErrors.length, 1); - assert.equal( - harness.controller().overlayBySession['session-a']?.model.model, - 'gpt-5.5', - ); -}); - -test('commits a model before its latest thinking level', async () => { - const modelWrite = deferred(); - const calls: string[] = []; - const harness = await mountIntent({ - setModel: async (_sessionId, model) => { - calls.push(`model:${model.model}`); - return modelWrite.promise; - }, - setThinkingLevel: async (_sessionId, level) => { - calls.push(`thinking:${level ?? 'default'}`); - return session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - thinkingLevel: level, - }); - }, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - harness.controller().selectThinkingLevel('session-a', 'high'); - }); - assert.deepEqual(calls, ['model:gpt-5.6-sol']); - modelWrite.resolve(session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - thinkingLevel: undefined, - })); - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(calls, ['model:gpt-5.6-sol', 'thinking:high']); - assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, 'high'); -}); - -test('a later model selection resets an in-flight thinking selection', async () => { - const thinkingWrite = deferred(); - const calls: string[] = []; - const harness = await mountIntent({ - setThinkingLevel: async (_sessionId, level) => { - calls.push(`thinking:${level ?? 'default'}`); - return thinkingWrite.promise; - }, - setModel: async (_sessionId, model) => { - calls.push(`model:${model.model}`); - return session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }); - }, - }); - - await act(async () => { - harness.controller().selectThinkingLevel('session-a', 'high'); - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - }); - assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, undefined); - assert.deepEqual(calls, ['thinking:high']); - thinkingWrite.resolve(session({ thinkingLevel: 'high' })); - - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(calls, ['thinking:high', 'model:gpt-5.6-sol']); - assert.equal(harness.controller().overlayBySession['session-a']?.thinkingLevel, undefined); -}); - -test('continues from a stale model failure to the newer desired model', async () => { - const first = deferred(); - const writes: string[] = []; - const harness = await mountIntent({ - setModel: async (_sessionId, model) => { - writes.push(model.model); - if (writes.length === 1) return first.promise; - return session({ llmConnectionSlug: model.llmConnectionSlug, model: model.model }); - }, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.5', - }); - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - }); - first.reject(new Error('superseded failure')); - - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(writes, ['gpt-5.5', 'gpt-5.6-sol']); - assert.deepEqual(harness.modelErrors, []); - assert.equal( - harness.controller().overlayBySession['session-a']?.model.model, - 'gpt-5.6-sol', - ); -}); - -test('does not apply an old-model thinking failure to the same level on a newer model', async () => { - const firstThinkingWrite = deferred(); - const calls: string[] = []; - const harness = await mountIntent({ - setModel: async (_sessionId, model) => { - calls.push(`model:${model.model}`); - return session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }); - }, - setThinkingLevel: async (_sessionId, level) => { - calls.push(`thinking:${level ?? 'default'}`); - if (calls.length === 1) return firstThinkingWrite.promise; - return session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - thinkingLevel: level, - }); - }, - }); - - await act(async () => { - harness.controller().selectThinkingLevel('session-a', 'high'); - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - harness.controller().selectThinkingLevel('session-a', 'high'); - }); - firstThinkingWrite.reject(new Error('old model rejected high')); - - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(calls, [ - 'thinking:high', - 'model:gpt-5.6-sol', - 'thinking:high', - ]); - assert.deepEqual(harness.thinkingErrors, []); - assert.deepEqual(harness.controller().overlayBySession['session-a'], { - model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, - thinkingLevel: 'high', - }); -}); - -test('rebases a pending thinking intent onto a model committed by another client', async () => { - const firstThinkingWrite = deferred(); - const thinkingWrites: Array = []; - const harness = await mountIntent({ - setModel: async (_sessionId, model) => session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }), - setThinkingLevel: async (_sessionId, level) => { - thinkingWrites.push(level); - if (thinkingWrites.length === 1) return firstThinkingWrite.promise; - return session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.5', - thinkingLevel: level, - }); - }, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - harness.controller().selectThinkingLevel('session-a', 'high'); - await Promise.resolve(); - }); - assert.deepEqual(thinkingWrites, ['high']); - - await harness.render(1, session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.5', - thinkingLevel: undefined, - })); - assert.equal( - harness.controller().overlayBySession['session-a']?.model.model, - 'gpt-5.5', - ); - - firstThinkingWrite.reject(new Error('old-model thinking rejected')); - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(thinkingWrites, ['high', 'high']); - assert.deepEqual(harness.thinkingErrors, []); - assert.deepEqual(harness.controller().overlayBySession['session-a'], { - model: { llmConnectionSlug: 'openai', model: 'gpt-5.5' }, - thinkingLevel: 'high', - }); -}); - -test('ignores a stale thinking success from the model replaced by another client', async () => { - const firstThinkingWrite = deferred(); - const thinkingWrites: Array = []; - const harness = await mountIntent({ - setModel: async (_sessionId, model) => session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }), - setThinkingLevel: async (_sessionId, level) => { - thinkingWrites.push(level); - if (thinkingWrites.length === 1) return firstThinkingWrite.promise; - return session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.5', - thinkingLevel: level, - }); - }, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - harness.controller().selectThinkingLevel('session-a', 'high'); - await Promise.resolve(); - }); - - await harness.render(1, session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.5', - thinkingLevel: undefined, - })); - firstThinkingWrite.resolve(session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - thinkingLevel: 'high', - })); - - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(thinkingWrites, ['high', 'high']); - assert.deepEqual(harness.controller().overlayBySession['session-a'], { - model: { llmConnectionSlug: 'openai', model: 'gpt-5.5' }, - thinkingLevel: 'high', - }); -}); - -test('keeps a successful mutation when an older catalog snapshot commits first', async () => { - const firstThinkingWrite = deferred(); - const thinkingWrites: Array = []; - const harness = await mountIntent({ - setThinkingLevel: async (_sessionId, level) => { - thinkingWrites.push(level); - if (thinkingWrites.length === 1) return firstThinkingWrite.promise; - throw new Error('a successful mutation must not be repeated'); - }, - refreshCatalog: async () => { - throw new Error('catalog refresh unavailable'); - }, - }); - - await act(async () => { - harness.controller().selectThinkingLevel('session-a', 'high'); - }); - await harness.render(1, session({ - thinkingLevel: 'low', - }), () => { - firstThinkingWrite.resolve(session({ thinkingLevel: 'high' })); - }); - - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(thinkingWrites, ['high']); - assert.deepEqual(harness.thinkingErrors, []); - assert.deepEqual(harness.controller().overlayBySession['session-a'], { - model: { llmConnectionSlug: 'anthropic', model: 'claude-sonnet' }, - thinkingLevel: 'high', - }); -}); - -test('adopts the authoritative model returned by a thinking mutation without retrying', async () => { - const thinkingWrites: Array = []; - const harness = await mountIntent({ - setThinkingLevel: async (_sessionId, level) => { - thinkingWrites.push(level); - if (thinkingWrites.length > 1) { - throw new Error('the authoritative response must settle the intent'); - } - return session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.5', - thinkingLevel: level, - }); - }, - refreshCatalog: async () => { - throw new Error('catalog refresh unavailable'); - }, - }); - - await act(async () => { - harness.controller().selectThinkingLevel('session-a', 'high'); - assert.equal(await harness.controller().settle('session-a'), true); - }); - - assert.deepEqual(thinkingWrites, ['high']); - assert.deepEqual(harness.thinkingErrors, []); - assert.deepEqual(harness.controller().overlayBySession['session-a'], { - model: { llmConnectionSlug: 'openai', model: 'gpt-5.5' }, - thinkingLevel: 'high', - }); - assert.equal(harness.controller().hasModelOverride('session-a'), true); -}); - -test('rolls back and reports a terminal first model failure', async () => { - const modelWrite = deferred(); - const harness = await mountIntent({ - setModel: async () => modelWrite.promise, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - }); - - const settling = harness.controller().settle('session-a'); - modelWrite.reject(new Error('model unavailable')); - await act(async () => assert.equal(await settling, false)); - assert.equal(harness.controller().overlayBySession['session-a'], undefined); - assert.equal(harness.modelErrors.length, 1); - assert.equal(harness.modelErrors[0]?.sessionId, 'session-a'); - assert.deepEqual(harness.savedModels, []); -}); - -test('keeps an earlier model commit when the latest thinking write fails', async () => { - const thinkingWrite = deferred(); - const harness = await mountIntent({ - setModel: async (_sessionId, model) => session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }), - setThinkingLevel: async () => thinkingWrite.promise, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - harness.controller().selectThinkingLevel('session-a', 'high'); - }); - - const settling = harness.controller().settle('session-a'); - thinkingWrite.reject(new Error('thinking unavailable')); - await act(async () => assert.equal(await settling, false)); - - assert.deepEqual(harness.controller().overlayBySession['session-a'], { - model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, - thinkingLevel: undefined, - }); - assert.equal(harness.thinkingErrors.length, 1); -}); - -test('retains a committed overlay when catalog refresh fails', async () => { - const harness = await mountIntent({ - refreshCatalog: async () => { - throw new Error('catalog unavailable'); - }, - }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - assert.equal(await harness.controller().settle('session-a'), true); - await Promise.resolve(); - }); - - assert.equal( - harness.controller().overlayBySession['session-a']?.model.model, - 'gpt-5.6-sol', - ); -}); - -test('a newer successful catalog revision retires a committed overlay', async () => { - const harness = await mountIntent(); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - assert.equal(await harness.controller().settle('session-a'), true); - }); - assert.ok(harness.controller().overlayBySession['session-a']); - - await harness.render(1, session({ - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - })); - - assert.equal(harness.controller().overlayBySession['session-a'], undefined); -}); - -test('clear prevents a late failure from restoring UI or reporting an error', async () => { - const modelWrite = deferred(); - const harness = await mountIntent({ setModel: async () => modelWrite.promise }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - harness.controller().clear('session-a'); - }); - modelWrite.reject(new Error('late failure')); - - await act(async () => { - assert.equal(await harness.controller().settle('session-a'), true); - await Promise.resolve(); - }); - - assert.equal(harness.controller().overlayBySession['session-a'], undefined); - assert.deepEqual(harness.modelErrors, []); -}); - -test('unmount invalidates late mutation failures', async () => { - const modelWrite = deferred(); - const harness = await mountIntent({ setModel: async () => modelWrite.promise }); - - await act(async () => { - harness.controller().selectModel('session-a', { - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - }); - }); - const settling = harness.controller().settle('session-a'); - await harness.unmount(); - modelWrite.reject(new Error('late failure')); - - assert.equal(await settling, false); - assert.deepEqual(harness.modelErrors, []); -}); - -test('projects only model settings and preserves identity without an overlay', () => { - const authoritative = session({ name: 'Keep me', hasUnread: true }); - assert.equal(projectSessionModelSettings(authoritative, undefined), authoritative); - - const projected = projectSessionModelSettings(authoritative, { - model: { llmConnectionSlug: 'openai', model: 'gpt-5.6-sol' }, - thinkingLevel: 'high', - }); - - assert.deepEqual(projected, { - ...authoritative, - llmConnectionSlug: 'openai', - model: 'gpt-5.6-sol', - thinkingLevel: 'high', - }); -}); - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((next, fail) => { - resolve = next; - reject = fail; - }); - return { promise, reject, resolve }; -} - -function session(overrides: Partial = {}): SessionSummary { - return { - id: 'session-a', - name: 'Session A', - isFlagged: false, - isArchived: false, - labels: [], - hasUnread: false, - status: 'active', - backend: 'fake', - llmConnectionSlug: 'anthropic', - connectionLocked: true, - model: 'claude-sonnet', - permissionMode: 'ask', - ...overrides, - }; -} - -type IntentBehavior = Omit< - SessionModelSettingsIntentOptions, - 'catalogRevision' | 'readAuthoritative' ->; - -async function mountIntent(overrides: Partial = {}) { - const { document, window } = parseHTML('
'); - Object.assign(globalThis, { - document, - window, - HTMLElement: window.HTMLElement, - Node: window.Node, - IS_REACT_ACT_ENVIRONMENT: true, - }); - const container = document.querySelector('#root'); - assert.ok(container); - mountedRoot = createRoot(container); - - let captured: SessionModelSettingsIntentController | undefined; - let authoritative = session(); - const modelErrors: Array<{ sessionId: string; error: unknown }> = []; - const thinkingErrors: Array<{ sessionId: string; error: unknown }> = []; - const savedModels: SessionModelTarget[] = []; - const saveComposerModelOverride = overrides.saveComposerModel; - const behavior: IntentBehavior = { - setModel: async (_sessionId, model) => session({ - llmConnectionSlug: model.llmConnectionSlug, - model: model.model, - thinkingLevel: undefined, - }), - setThinkingLevel: async (_sessionId, level) => session({ thinkingLevel: level }), - refreshCatalog: async () => undefined, - onModelError: (sessionId, error) => modelErrors.push({ sessionId, error }), - onThinkingError: (sessionId, error) => thinkingErrors.push({ sessionId, error }), - ...overrides, - saveComposerModel: (model) => { - savedModels.push(model); - saveComposerModelOverride?.(model); - }, - }; - - const render = async ( - catalogRevision: number, - nextAuthoritative: SessionSummary = authoritative, - afterLayout?: () => void, - ) => { - authoritative = nextAuthoritative; - const options: SessionModelSettingsIntentOptions = { - ...behavior, - catalogRevision, - readAuthoritative: (sessionId) => - sessionId === authoritative.id ? authoritative : undefined, - }; - await act(async () => { - mountedRoot?.render(createElement(Harness, { - options, - afterLayout, - capture: (controller) => { - captured = controller; - }, - })); - }); - }; - - await render(0); - - return { - controller: () => { - assert.ok(captured); - return captured; - }, - modelErrors, - savedModels, - thinkingErrors, - render, - unmount: async () => { - await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - }, - }; -} - -function Harness({ - options, - afterLayout, - capture, -}: { - options: SessionModelSettingsIntentOptions; - afterLayout?: () => void; - capture(controller: SessionModelSettingsIntentController): void; -}) { - const controller = useSessionModelSettingsIntent(options); - useLayoutEffect(() => afterLayout?.(), [afterLayout]); - capture(controller); - return createElement('output', { - 'data-model': controller.overlayBySession['session-a']?.model.model, - }); -} diff --git a/apps/desktop/src/main/__tests__/session-setting-intent.test.ts b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts index 8b5fdebacc..56350d0b2f 100644 --- a/apps/desktop/src/main/__tests__/session-setting-intent.test.ts +++ b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts @@ -24,8 +24,9 @@ import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { useSessionSettingIntent } from '../../renderer/use-session-setting-intent.js'; -type SessionSettingIntentController = ReturnType< - typeof useSessionSettingIntent +type IntentValue = string | null; +type SessionSettingIntentController = ReturnType< + typeof useSessionSettingIntent >; const originalGlobals = { @@ -45,7 +46,17 @@ afterEach(async () => { Object.assign(globalThis, originalGlobals); }); -test('Runtime leaving Plan after approval supersedes the committed Plan overlay', async () => { +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, reject, resolve }; +} + +function installDom() { const { document, window } = parseHTML('
'); Object.assign(globalThis, { document, @@ -58,13 +69,53 @@ test('Runtime leaving Plan after approval supersedes the committed Plan overlay' assert.ok(container); const root = createRoot(container); mountedRoot = root; + return { container, root }; +} - let controller: SessionSettingIntentController | undefined; - const render = async (catalogRevision: number, catalogValue: boolean) => { +function Harness({ + catalogRevision, + catalogValue, + write, + refreshCatalog = async () => { + throw new Error('catalog unavailable'); + }, + onWriteError = () => {}, + capture, +}: { + catalogRevision: number; + catalogValue: IntentValue; + write(sessionId: string, value: IntentValue): Promise; + refreshCatalog?(): Promise; + onWriteError?(sessionId: string, error: unknown): void; + capture(controller: SessionSettingIntentController): void; +}) { + const controller = useSessionSettingIntent({ + catalogRevision, + write, + refreshCatalog, + onWriteError, + }); + capture(controller); + const ownsOverlay = Object.prototype.hasOwnProperty.call( + controller.overlayBySession, + 'session-1', + ); + const value = ownsOverlay ? controller.overlayBySession['session-1'] : catalogValue; + return createElement('output', { + 'data-value': value === null ? 'null' : value, + 'data-owns-overlay': ownsOverlay.toString(), + }); +} + +test('Runtime leaving Plan after approval supersedes the committed Plan overlay', async () => { + const { container, root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const render = async (catalogRevision: number, catalogValue: IntentValue) => { await act(async () => { root.render(createElement(Harness, { catalogRevision, catalogValue, + write: async () => true, capture: (next) => { controller = next; }, @@ -72,39 +123,130 @@ test('Runtime leaving Plan after approval supersedes the committed Plan overlay' }); }; - await render(0, false); + await render(0, 'agent'); await act(async () => { - await controller?.request('session-1', true); + await controller?.request('session-1', 'plan'); }); - assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'true'); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'plan'); - // Runtime automatically leaves Plan after approval. This is a successful, - // causally newer catalog observation, so its Agent value must win even - // though it differs from the renderer's earlier committed Plan value. - await render(1, false); + await render(1, 'agent'); - assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'false'); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'agent'); }); -function Harness({ - catalogRevision, - catalogValue, - capture, -}: { - catalogRevision: number; - catalogValue: boolean; - capture(controller: SessionSettingIntentController): void; -}) { - const controller = useSessionSettingIntent({ - catalogRevision, - write: async () => true, - refreshCatalog: async () => { - throw new Error('catalog unavailable'); - }, - onWriteError: () => {}, +test('coalesces rapid requests to the latest desired value', async () => { + const { container, root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const writes: Array<{ + value: IntentValue; + result: ReturnType>; + }> = []; + + await act(async () => { + root.render(createElement(Harness, { + catalogRevision: 0, + catalogValue: 'model-a', + write: async (_sessionId, value) => { + const result = deferred(); + writes.push({ value, result }); + return result.promise; + }, + capture: (next) => { + controller = next; + }, + })); }); - capture(controller); - return createElement('output', { - 'data-value': (controller.overlayBySession['session-1'] ?? catalogValue).toString(), + + let worker: Promise | undefined; + await act(async () => { + worker = controller?.request('session-1', 'model-b'); + await Promise.resolve(); }); -} + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'model-b'); + + await act(async () => { + await controller?.request('session-1', 'model-c'); + }); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'model-c'); + assert.deepEqual(writes.map((entry) => entry.value), ['model-b']); + + await act(async () => { + writes[0]?.result.resolve(true); + await Promise.resolve(); + }); + assert.deepEqual(writes.map((entry) => entry.value), ['model-b', 'model-c']); + + await act(async () => { + writes[1]?.result.resolve(true); + await worker; + }); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'model-c'); +}); + +test('rolls a terminal failure back to the last committed value', async () => { + const { container, root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const errors: Array<{ sessionId: string; error: unknown }> = []; + + await act(async () => { + root.render(createElement(Harness, { + catalogRevision: 0, + catalogValue: 'default', + write: async (_sessionId, value) => { + if (value === 'low') throw new Error('fixture failure'); + return true; + }, + onWriteError: (sessionId, error) => { + errors.push({ sessionId, error }); + }, + capture: (next) => { + controller = next; + }, + })); + }); + + await act(async () => { + await controller?.request('session-1', 'high'); + }); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'high'); + + await act(async () => { + assert.equal(await controller?.request('session-1', 'low'), false); + }); + + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'high'); + assert.equal(errors.length, 1); + assert.equal(errors[0]?.sessionId, 'session-1'); +}); + +test('keeps null as an explicit optimistic default value', async () => { + const { container, root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const result = deferred(); + + await act(async () => { + root.render(createElement(Harness, { + catalogRevision: 0, + catalogValue: 'high', + write: async () => result.promise, + capture: (next) => { + controller = next; + }, + })); + }); + + let worker: Promise | undefined; + await act(async () => { + worker = controller?.request('session-1', null); + await Promise.resolve(); + }); + + const output = container.querySelector('output'); + assert.equal(output?.getAttribute('data-owns-overlay'), 'true'); + assert.equal(output?.getAttribute('data-value'), 'null'); + + await act(async () => { + result.resolve(true); + await worker; + }); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f46d34bf4c..cb8fa9ae1c 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -35,6 +35,7 @@ import type { QuoteRef, } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; @@ -131,10 +132,6 @@ import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { useShellSearch } from './use-shell-search'; import { useSessionSettingIntent } from './use-session-setting-intent'; -import { - projectSessionModelSettings, - useSessionModelSettingsIntent, -} from './use-session-model-settings-intent'; import { deriveStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; import { deriveProjectGroups, deriveWorktreeSessionIds } from './session-project-grouping'; @@ -226,6 +223,11 @@ import { useShellLiveTurn } from './use-shell-live-turn'; import { useShellLayout } from './use-shell-layout'; import { useShellResume } from './use-shell-resume'; +type SessionModelTarget = { + llmConnectionSlug: string; + model: string; +}; + function rebaseWorkspaceFileReferences( sourceText: string, projectedText: string, @@ -745,25 +747,37 @@ function AppShellContent({ const activeSession = sessions.find((session) => session.id === activeId); const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; const activeDesktopSession = activeSession; - const modelSettingsIntent = useSessionModelSettingsIntent({ + const modelIntent = useSessionSettingIntent({ catalogRevision, - readAuthoritative: (sessionId) => - sessionsRef.current.find((session) => session.id === sessionId), - setModel: (sessionId, model) => window.maka.sessions.setModel(sessionId, model), - setThinkingLevel: (sessionId, level) => - window.maka.sessions.setThinkingLevel(sessionId, level), + write: async (sessionId, target) => { + const summary = await window.maka.sessions.setModel(sessionId, target); + const committed = summary.llmConnectionSlug === target.llmConnectionSlug && + summary.model === target.model; + if (committed) saveComposerDefaults({ model: target }); + return committed; + }, refreshCatalog: refreshSessions, - saveComposerModel: (model) => saveComposerDefaults({ model }), - onModelError: (sessionId, error) => { - if (!isComposerImportOwnerActive({ sessionId, navSection: 'sessions' })) return; + onWriteError: (sessionId, error) => { + if (activeIdRef.current !== sessionId) return; showSessionError( sessionId, sessionSettingsCopy.modelFailedTitle, localizedShellErrorMessage(error, sessionSettingsCopy.modelFallback, uiLocale), ); }, - onThinkingError: (sessionId, error) => { - if (!isComposerImportOwnerActive({ sessionId, navSection: 'sessions' })) return; + }); + const thinkingLevelIntent = useSessionSettingIntent({ + catalogRevision, + write: async (sessionId, level) => { + const summary = await window.maka.sessions.setThinkingLevel( + sessionId, + level ?? undefined, + ); + return summary.thinkingLevel === (level ?? undefined); + }, + refreshCatalog: refreshSessions, + onWriteError: (sessionId, error) => { + if (activeIdRef.current !== sessionId) return; showSessionError( sessionId, sessionSettingsCopy.thinkingFailedTitle, @@ -771,11 +785,26 @@ function AppShellContent({ ); }, }); + const modelOverlay = activeSession + ? modelIntent.overlayBySession[activeSession.id] + : undefined; + const ownsThinkingOverlay = Boolean( + activeSession && Object.hasOwn(thinkingLevelIntent.overlayBySession, activeSession.id), + ); + const thinkingOverlay = activeSession && ownsThinkingOverlay + ? thinkingLevelIntent.overlayBySession[activeSession.id] + : undefined; const activeSessionForModelControls = activeSession - ? projectSessionModelSettings( - activeSession, - modelSettingsIntent.overlayBySession[activeSession.id], - ) + ? { + ...activeSession, + ...(modelOverlay + ? { + llmConnectionSlug: modelOverlay.llmConnectionSlug, + model: modelOverlay.model, + } + : {}), + ...(ownsThinkingOverlay ? { thinkingLevel: thinkingOverlay ?? undefined } : {}), + } : undefined; // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the @@ -911,7 +940,8 @@ function AppShellContent({ permissionModeChangeRegistry.keysRef.current.delete(sessionId); planModeIntent.clear(sessionId); orchestrationModeIntent.clear(sessionId); - modelSettingsIntent.clear(sessionId); + modelIntent.clear(sessionId); + thinkingLevelIntent.clear(sessionId); } const sessionRowActionHandlers = useStableActions(createAppShellSessionRowActions, { @@ -2953,12 +2983,12 @@ function AppShellContent({ modelSwitchHasHistory={modelSwitchHasHistory} renderProviderMark={(type) => } onModelChange={(input) => { - if (activeId) modelSettingsIntent.selectModel(activeId, input); + if (activeId) void modelIntent.request(activeId, input); }} activeThinkingLevels={activeThinkingLevels} activeThinkingLevel={activeThinkingLevel} onThinkingLevelChange={(level) => { - if (activeId) modelSettingsIntent.selectThinkingLevel(activeId, level); + if (activeId) void thinkingLevelIntent.request(activeId, level ?? null); }} newChatModel={newChatModel} newChatProviderType={newChatProviderType} @@ -3059,7 +3089,7 @@ function AppShellContent({ renderProviderMark={(type) => } modelChoices={chatModelChoices} onModelChange={(input) => { - if (activeId) modelSettingsIntent.selectModel(activeId, input); + if (activeId) void modelIntent.request(activeId, input); }} userLabel={userLabel} memoryActive={memoryActive} diff --git a/apps/desktop/src/renderer/use-session-model-settings-intent.ts b/apps/desktop/src/renderer/use-session-model-settings-intent.ts deleted file mode 100644 index 9fb53bc7fe..0000000000 --- a/apps/desktop/src/renderer/use-session-model-settings-intent.ts +++ /dev/null @@ -1,420 +0,0 @@ -/* - * 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 type { ThinkingLevel } from '@maka/core/model-thinking'; -import type { SessionSummary } from '@maka/core/session'; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; - -export interface SessionModelTarget { - llmConnectionSlug: string; - model: string; -} - -export interface SessionModelSettings { - model: SessionModelTarget; - thinkingLevel: ThinkingLevel | undefined; -} - -export interface SessionModelSettingsIntentOptions { - catalogRevision: number; - readAuthoritative(sessionId: string): SessionSummary | undefined; - setModel(sessionId: string, model: SessionModelTarget): Promise; - setThinkingLevel( - sessionId: string, - level: ThinkingLevel | undefined, - ): Promise; - refreshCatalog(): Promise; - saveComposerModel(model: SessionModelTarget): void; - onModelError(sessionId: string, error: unknown): void; - onThinkingError(sessionId: string, error: unknown): void; -} - -export interface SessionModelSettingsIntentController { - overlayBySession: Readonly>; - hasModelOverride(sessionId: string): boolean; - read(sessionId: string): SessionModelSettings | undefined; - selectModel(sessionId: string, model: SessionModelTarget): void; - selectThinkingLevel(sessionId: string, level: ThinkingLevel | undefined): void; - settle(sessionId: string): Promise; - clear(sessionId: string): void; -} - -interface ModelSettingsIntent { - desired: SessionModelSettings; - committed: SessionModelSettings; - nextVersion: number; - catalogGeneration: number; - hasModelSelection: boolean; - pendingModelVersion?: number; - pendingThinkingVersion?: number; - committedAtCatalogRevision?: number; - inFlight?: Promise; -} - -function settingsFromSession(session: SessionSummary): SessionModelSettings { - return { - model: { - llmConnectionSlug: session.llmConnectionSlug, - model: session.model, - }, - thinkingLevel: session.thinkingLevel, - }; -} - -function sameModel(left: SessionModelTarget, right: SessionModelTarget): boolean { - return left.llmConnectionSlug === right.llmConnectionSlug && left.model === right.model; -} - -function sameSettings( - left: SessionModelSettings, - right: SessionModelSettings, -): boolean { - return sameModel(left.model, right.model) && left.thinkingLevel === right.thinkingLevel; -} - -function applyAuthoritativeSettings( - intent: ModelSettingsIntent, - authoritative: SessionModelSettings, -): boolean { - const previousDesired = intent.desired; - intent.committed = authoritative; - intent.desired = { - model: intent.pendingModelVersion !== undefined - ? previousDesired.model - : authoritative.model, - thinkingLevel: intent.pendingThinkingVersion !== undefined - ? previousDesired.thinkingLevel - : authoritative.thinkingLevel, - }; - return !sameSettings(previousDesired, intent.desired); -} - -function hasPendingSettings(intent: ModelSettingsIntent): boolean { - return intent.pendingModelVersion !== undefined || - intent.pendingThinkingVersion !== undefined; -} - -function responsePredatesCatalogModel( - intent: ModelSettingsIntent, - response: SessionModelSettings, - catalogGenerationAtStart: number, -): boolean { - return intent.catalogGeneration > catalogGenerationAtStart && - !sameModel(intent.committed.model, response.model); -} - -export function projectSessionModelSettings( - session: T, - overlay: SessionModelSettings | undefined, -): T { - if (!overlay) return session; - return { - ...session, - llmConnectionSlug: overlay.model.llmConnectionSlug, - model: overlay.model.model, - thinkingLevel: overlay.thinkingLevel, - }; -} - -export function useSessionModelSettingsIntent( - options: SessionModelSettingsIntentOptions, -): SessionModelSettingsIntentController { - const optionsRef = useRef(options); - useLayoutEffect(() => { - optionsRef.current = options; - }); - const intentsRef = useRef(new Map()); - const [overlayBySession, setOverlayBySession] = useState< - Record - >({}); - - useEffect(() => () => { - intentsRef.current.clear(); - }, []); - - const setOverlay = useCallback((sessionId: string, value: SessionModelSettings | undefined) => { - setOverlayBySession((current) => { - if (value) return { ...current, [sessionId]: value }; - if (!(sessionId in current)) return current; - const next = { ...current }; - delete next[sessionId]; - return next; - }); - }, []); - - const reconcile = useCallback((sessionId: string): void => { - const intent = intentsRef.current.get(sessionId); - if ( - !intent || - intent.inFlight || - intent.committedAtCatalogRevision === undefined || - optionsRef.current.catalogRevision <= intent.committedAtCatalogRevision - ) { - return; - } - intentsRef.current.delete(sessionId); - setOverlay(sessionId, undefined); - }, [setOverlay]); - - useLayoutEffect(() => { - for (const [sessionId, intent] of intentsRef.current) { - if (intent.inFlight) { - const authoritative = optionsRef.current.readAuthoritative(sessionId); - if (authoritative) { - const authoritativeSettings = settingsFromSession(authoritative); - if (!sameSettings(intent.committed, authoritativeSettings)) { - intent.catalogGeneration += 1; - } - const rebased = applyAuthoritativeSettings(intent, authoritativeSettings); - intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; - if (rebased) setOverlay(sessionId, intent.desired); - } - } - reconcile(sessionId); - } - }, [options.catalogRevision, reconcile, setOverlay]); - - const refreshCatalogInBackground = useCallback((): void => { - try { - void optionsRef.current.refreshCatalog().catch(() => undefined); - } catch { - // Refresh is a convergence nudge. The committed overlay remains until a - // later successful catalog revision can retire it. - } - }, []); - - const failLatest = useCallback(( - sessionId: string, - intent: ModelSettingsIntent, - field: 'model' | 'thinking', - error: unknown, - ): false => { - intent.pendingModelVersion = undefined; - intent.pendingThinkingVersion = undefined; - intent.desired = intent.committed; - if (field === 'model') optionsRef.current.onModelError(sessionId, error); - else optionsRef.current.onThinkingError(sessionId, error); - - if (intent.committedAtCatalogRevision === undefined) { - intentsRef.current.delete(sessionId); - setOverlay(sessionId, undefined); - } else { - setOverlay(sessionId, intent.committed); - refreshCatalogInBackground(); - } - return false; - }, [refreshCatalogInBackground, setOverlay]); - - const runWorker = useCallback(async ( - sessionId: string, - intent: ModelSettingsIntent, - ): Promise => { - while (intentsRef.current.get(sessionId) === intent) { - const desired = intent.desired; - if (intent.pendingModelVersion !== undefined) { - const attempted = desired.model; - const attemptedModelVersion = intent.pendingModelVersion; - const attemptedThinkingVersion = intent.pendingThinkingVersion; - const catalogGenerationAtStart = intent.catalogGeneration; - try { - const result = await optionsRef.current.setModel(sessionId, attempted); - if (intentsRef.current.get(sessionId) !== intent) return false; - const authoritative = settingsFromSession(result); - if (responsePredatesCatalogModel(intent, authoritative, catalogGenerationAtStart)) { - continue; - } - if ( - intent.pendingModelVersion === attemptedModelVersion && - sameModel(intent.desired.model, authoritative.model) - ) { - intent.pendingModelVersion = undefined; - } - if ( - intent.pendingThinkingVersion === attemptedThinkingVersion && - intent.desired.thinkingLevel === authoritative.thinkingLevel - ) { - intent.pendingThinkingVersion = undefined; - } - applyAuthoritativeSettings(intent, authoritative); - intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; - optionsRef.current.saveComposerModel(attempted); - } catch (error) { - if (intentsRef.current.get(sessionId) !== intent) return false; - if (intent.pendingModelVersion === attemptedModelVersion) { - return failLatest(sessionId, intent, 'model', error); - } - } - continue; - } - if (intent.pendingThinkingVersion !== undefined) { - const attempted = desired.thinkingLevel; - const attemptedModel = desired.model; - const attemptedThinkingVersion = intent.pendingThinkingVersion; - const catalogGenerationAtStart = intent.catalogGeneration; - try { - const result = await optionsRef.current.setThinkingLevel(sessionId, attempted); - if (intentsRef.current.get(sessionId) !== intent) return false; - const authoritative = settingsFromSession(result); - if (responsePredatesCatalogModel(intent, authoritative, catalogGenerationAtStart)) { - continue; - } - if ( - intent.pendingThinkingVersion === attemptedThinkingVersion && - intent.desired.thinkingLevel === authoritative.thinkingLevel - ) { - intent.pendingThinkingVersion = undefined; - } - applyAuthoritativeSettings(intent, authoritative); - intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; - } catch (error) { - if (intentsRef.current.get(sessionId) !== intent) return false; - if ( - intent.pendingThinkingVersion === attemptedThinkingVersion && - sameModel(intent.desired.model, attemptedModel) && - intent.desired.thinkingLevel === attempted - ) { - return failLatest(sessionId, intent, 'thinking', error); - } - } - continue; - } - break; - } - - if (intentsRef.current.get(sessionId) !== intent) return false; - if (intent.committedAtCatalogRevision === undefined) { - intentsRef.current.delete(sessionId); - setOverlay(sessionId, undefined); - } else { - setOverlay(sessionId, intent.committed); - refreshCatalogInBackground(); - } - return true; - }, [failLatest, refreshCatalogInBackground, setOverlay]); - - const startWorker = useCallback((sessionId: string, intent: ModelSettingsIntent): void => { - if (intent.inFlight) return; - const launch = (): Promise => { - let worker: Promise; - worker = runWorker(sessionId, intent).then( - (result) => { - if ( - intentsRef.current.get(sessionId) !== intent || - intent.inFlight !== worker - ) { - return result; - } - intent.inFlight = undefined; - if (hasPendingSettings(intent)) { - const continuation = launch(); - intent.inFlight = continuation; - return continuation; - } - reconcile(sessionId); - return result; - }, - (error: unknown) => { - if ( - intentsRef.current.get(sessionId) === intent && - intent.inFlight === worker - ) { - intent.inFlight = undefined; - reconcile(sessionId); - } - throw error; - }, - ); - return worker; - }; - intent.inFlight = launch(); - }, [reconcile, runWorker]); - - const getOrCreateIntent = useCallback((sessionId: string): ModelSettingsIntent | undefined => { - const existing = intentsRef.current.get(sessionId); - if (existing) return existing; - const authoritative = optionsRef.current.readAuthoritative(sessionId); - if (!authoritative) return undefined; - const committed = settingsFromSession(authoritative); - const intent = { - committed, - desired: committed, - nextVersion: 0, - catalogGeneration: 0, - hasModelSelection: false, - }; - intentsRef.current.set(sessionId, intent); - return intent; - }, []); - - const selectModel = useCallback((sessionId: string, model: SessionModelTarget): void => { - const intent = getOrCreateIntent(sessionId); - if (!intent) return; - const version = ++intent.nextVersion; - intent.hasModelSelection = true; - intent.desired = { model, thinkingLevel: undefined }; - intent.pendingModelVersion = version; - intent.pendingThinkingVersion = version; - setOverlay(sessionId, intent.desired); - startWorker(sessionId, intent); - }, [getOrCreateIntent, setOverlay, startWorker]); - - const selectThinkingLevel = useCallback(( - sessionId: string, - thinkingLevel: ThinkingLevel | undefined, - ): void => { - const intent = getOrCreateIntent(sessionId); - if (!intent) return; - intent.pendingThinkingVersion = ++intent.nextVersion; - intent.desired = { ...intent.desired, thinkingLevel }; - setOverlay(sessionId, intent.desired); - startWorker(sessionId, intent); - }, [getOrCreateIntent, setOverlay, startWorker]); - - const settle = useCallback(async (sessionId: string): Promise => { - return intentsRef.current.get(sessionId)?.inFlight ?? true; - }, []); - const read = useCallback((sessionId: string): SessionModelSettings | undefined => { - const intent = intentsRef.current.get(sessionId); - if (intent) return intent.desired; - const authoritative = optionsRef.current.readAuthoritative(sessionId); - return authoritative ? settingsFromSession(authoritative) : undefined; - }, []); - const hasModelOverride = useCallback((sessionId: string): boolean => { - const intent = intentsRef.current.get(sessionId); - if (!intent) return false; - if (intent.hasModelSelection) return true; - const authoritative = optionsRef.current.readAuthoritative(sessionId); - return !authoritative || - !sameModel(intent.desired.model, settingsFromSession(authoritative).model); - }, []); - const clear = useCallback((sessionId: string): void => { - intentsRef.current.delete(sessionId); - setOverlay(sessionId, undefined); - }, [setOverlay]); - - return { - overlayBySession, - hasModelOverride, - read, - selectModel, - selectThinkingLevel, - settle, - clear, - }; -} From 3b6ced587265bbeb47ac0b0e5b1e3625ae57168e Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:09:27 +0800 Subject: [PATCH 10/24] refactor(desktop): unify permission setting intent Generated-by: OpenAI Codex --- ...app-shell-session-settings-actions.test.ts | 182 ------------------ .../app-shell-session-ui-state.test.ts | 9 +- .../model-settings-visual-contract.test.ts | 14 +- .../desktop/src/renderer/app-shell-effects.ts | 2 - .../app-shell-session-settings-actions.ts | 139 ------------- .../renderer/app-shell-session-ui-state.ts | 6 +- apps/desktop/src/renderer/app-shell.tsx | 106 +++++++--- .../tools/side-chat/quote-companion-panel.tsx | 7 +- .../src/renderer/locales/conversation-copy.ts | 3 + .../src/renderer/locales/shell-copy.ts | 25 +-- .../use-app-shell-session-ui-reads.ts | 3 - .../use-app-shell-session-workspace.ts | 1 - packages/ui/src/composer.tsx | 4 +- 13 files changed, 104 insertions(+), 397 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts delete mode 100644 apps/desktop/src/renderer/app-shell-session-settings-actions.ts diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts deleted file mode 100644 index d8afc52ac1..0000000000 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -/* - * 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 { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; -import { createAppShellSessionSettingsActions } from '../../renderer/app-shell-session-settings-actions.js'; - -function session(id: string): DesktopSessionSummary { - return { - id, - name: id, - isFlagged: false, - isArchived: false, - labels: [], - hasUnread: false, - status: 'active', - backend: 'fake', - llmConnectionSlug: 'e2e', - connectionLocked: true, - model: 'claude-sonnet', - permissionMode: 'ask', - runtimeHostId: 'host-local', - profileId: 'local', - profileName: 'Local', - profileKind: 'local', - }; -} - -function createHarness(options: { - confirm?: () => Promise; - permissionModeResult?: 'ask' | 'bypass'; - permissionFailure?: Error; -} = {}) { - const activeIdRef = { current: 'session-a' as string | undefined }; - const sessionsRef = { current: [session('session-a'), session('session-b')] }; - const permissionCalls: string[] = []; - const errors: string[] = []; - const errorTargets: Array<{ sessionId: string } | undefined> = []; - const successes: Array<{ title: string; description?: string }> = []; - const newTaskPermissionModes: string[] = []; - let refreshCount = 0; - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - maka: { - sessions: { - setPermissionMode: async (sessionId: string, mode: 'ask' | 'bypass') => { - permissionCalls.push(`${sessionId}:${mode}`); - if (options.permissionFailure) throw options.permissionFailure; - return { - ...session(sessionId), - permissionMode: options.permissionModeResult ?? mode, - }; - }, - }, - }, - }, - }); - - const actions = createAppShellSessionSettingsActions({ - uiLocale: 'zh', - activeIdRef, - pendingPermissionModeChangesRef: { current: new Set() }, - refreshSessions: async () => { - refreshCount += 1; - return sessionsRef.current; - }, - sessionsRef, - setNewTaskPermissionMode: (mode) => void newTaskPermissionModes.push(mode), - setPendingPermissionModeBySession: () => undefined, - toastApi: { - success: (title, description) => successes.push({ title, description }), - error: (title, _description, _details, target) => { - errors.push(title); - errorTargets.push(target); - }, - confirm: options.confirm ?? (async () => true), - }, - }); - - return { - actions, - activeIdRef, - errors, - errorTargets, - newTaskPermissionModes, - permissionCalls, - get refreshCount() { - return refreshCount; - }, - sessionsRef, - successes, - }; -} - -describe('AppShell session settings actions', () => { - it('keeps a new-task permission choice in the draft instead of mutating a Host default', async () => { - const harness = createHarness(); - harness.activeIdRef.current = undefined; - - assert.equal(await harness.actions.setPermissionMode('bypass'), true); - assert.deepEqual(harness.newTaskPermissionModes, ['bypass']); - assert.deepEqual(harness.permissionCalls, []); - }); - - it('does not grant full access when its confirmation is cancelled', async () => { - let confirmations = 0; - const harness = createHarness({ - confirm: async () => { - confirmations += 1; - return false; - }, - }); - - assert.equal(await harness.actions.setPermissionMode('bypass'), false); - assert.equal(confirmations, 1); - assert.deepEqual(harness.permissionCalls, []); - }); - - it('reports a confirmed bypass switch as successful', async () => { - const harness = createHarness(); - - assert.equal(await harness.actions.setPermissionMode('bypass'), true); - assert.deepEqual(harness.permissionCalls, ['session-a:bypass']); - assert.equal(harness.refreshCount, 1); - assert.deepEqual(harness.successes, [{ - title: '已切到 完全权限', - description: '本地工具直接访问你的文件和网络,不经 Maka 的保护层。', - }]); - }); - - it('does not report success when the Host returns another permission mode', async () => { - const harness = createHarness({ permissionModeResult: 'ask' }); - - assert.equal(await harness.actions.setPermissionMode('bypass'), false); - assert.deepEqual(harness.permissionCalls, ['session-a:bypass']); - }); - - it('treats an already-active permission mode as successful without prompting', async () => { - let confirmations = 0; - const harness = createHarness({ - confirm: async () => { - confirmations += 1; - return true; - }, - }); - harness.sessionsRef.current = [{ - ...session('session-a'), - permissionMode: 'bypass', - }]; - - assert.equal(await harness.actions.setPermissionMode('bypass'), true); - assert.equal(confirmations, 0); - assert.deepEqual(harness.permissionCalls, []); - }); - - it('preserves localized permission failure feedback and its Session target', async () => { - const harness = createHarness({ permissionFailure: new Error('fixture failure') }); - - assert.equal(await harness.actions.setPermissionMode('bypass'), false); - assert.deepEqual(harness.errors, ['切换权限模式失败']); - assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); - }); -}); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index aa788bf556..2a4b0e2496 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -70,7 +70,6 @@ function seededState(): AppShellSessionUiState { drop: [boundaryRequest('drop')], keep: [boundaryRequest('keep')], }, - pendingPermissionModeBySession: { drop: true, keep: true }, }; } @@ -90,6 +89,13 @@ describe('session live run display state', () => { }); describe('app shell session UI state controller', () => { + it('does not mirror session-setting writes into UI pending state', () => { + assert.equal( + 'pendingPermissionModeBySession' in createInitialAppShellSessionUiState(), + false, + ); + }); + it('selects background terminal sessions without cutting off the active handoff', () => { const sessions = [ { id: 'running', status: 'running' }, @@ -182,7 +188,6 @@ describe('app shell session UI state controller', () => { assert.deepEqual(Object.keys(next.stopPendingBySession), ['keep']); assert.deepEqual(Object.keys(next.liveTurnBySession), ['keep']); assert.deepEqual(Object.keys(next.interactionBySession), ['keep']); - assert.deepEqual(Object.keys(next.pendingPermissionModeBySession), ['keep']); }); it('keeps state identity for no-op map updates and only replaces the selected map', () => { diff --git a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts index 732fa787e7..151634fe65 100644 --- a/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-settings-visual-contract.test.ts @@ -76,13 +76,13 @@ test('model setting intent never orders renderer send paths', () => { assert.doesNotMatch(followUpSource, /submitFollowUpAfterModelSettings/); }); -test('model setting failures stay on the chat surface that owns the session', () => { +test('session setting failures stay on the chat surface that owns the session', () => { const intentOptions = appShellSource.slice( appShellSource.indexOf('const modelIntent = useSessionSettingIntent'), appShellSource.indexOf('const activeSessionForModelControls'), ); const activeSessionGuards = intentOptions.match(/activeIdRef\.current !== sessionId/g); - assert.equal(activeSessionGuards?.length, 2); + assert.equal(activeSessionGuards?.length, 3); }); test('model and thinking settings reuse the generic intent seam', () => { @@ -99,6 +99,16 @@ test('model and thinking settings reuse the generic intent seam', () => { assert.match(appShellSource, /thinkingLevelIntent\.request\(activeId, level \?\? null\)/); }); +test('permission mode reuses the generic intent seam without pending state', () => { + assert.match( + appShellSource, + /const permissionModeIntent = useSessionSettingIntent\(\{/, + ); + assert.doesNotMatch(appShellSource, /permissionModeChangeRegistry/); + assert.doesNotMatch(appShellSource, /pendingPermissionModeBySession/); + assert.doesNotMatch(composerSource, /permissionModePending/); +}); + test('optimistic settings stay inside model-control state', () => { assert.match(appShellSource, /sessionHealthSession:\s*activeSession/); assert.match( diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 588225d4f4..f3f3bf09b7 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -186,7 +186,6 @@ export function useAppShellBootstrapSubscriptions(options: { handleConnectionEvent: (event: ConnectionEvent) => void; openHelp: () => void; openSettings: () => void; - pendingPermissionModeChangesRef: RefBox>; pendingTurnActionTimersRef: RefBox>>; pendingTurnActionsRef: RefBox>; projectPickerPendingRef: RefBox; @@ -319,7 +318,6 @@ export function useAppShellBootstrapSubscriptions(options: { } options.pendingTurnActionTimersRef.current.clear(); options.pendingTurnActionsRef.current.clear(); - options.pendingPermissionModeChangesRef.current.clear(); }); useEffect(() => { diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts deleted file mode 100644 index 16026a8bad..0000000000 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ /dev/null @@ -1,139 +0,0 @@ -/* - * 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 type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { PermissionMode } from '@maka/core/permission'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; - -type RefBox = { current: T }; -type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; - -type ToastApi = { - success(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { sessionId: string }, - ): void; - confirm(input: { - title: string; - description?: string; - confirmLabel?: string; - cancelLabel?: string; - destructive?: boolean; - }): Promise; -}; - -export interface AppShellSessionSettingsActions { - setPermissionMode(mode: PermissionMode): Promise; -} - -export function createAppShellSessionSettingsActions(deps: { - uiLocale: UiLocale; - activeIdRef: RefBox; - pendingPermissionModeChangesRef: RefBox>; - refreshSessions: () => Promise; - sessionsRef: RefBox; - /** Persists the chat default; awaited so a failure surfaces as one. */ - setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void | Promise; - setPendingPermissionModeBySession: BooleanRecordUpdater; - toastApi: ToastApi; -}): AppShellSessionSettingsActions { - const { - uiLocale, - activeIdRef, - pendingPermissionModeChangesRef, - refreshSessions, - sessionsRef, - setNewTaskPermissionMode, - setPendingPermissionModeBySession, - toastApi, - } = deps; - const copy = getShellCopy(uiLocale).sessionSettingsActions; - - function omitSessionKey(current: Record, sessionId: string): Record { - if (!(sessionId in current)) return current; - const next = { ...current }; - delete next[sessionId]; - return next; - } - - async function setPermissionMode(mode: PermissionMode): Promise { - if (mode !== 'ask' && mode !== 'bypass') return false; - const sessionId = activeIdRef.current; - const currentMode = sessionId - ? sessionsRef.current.find((session) => session.id === sessionId)?.permissionMode - : undefined; - if (currentMode === mode) return true; - const pendingKey = sessionId ?? '__global_permission_mode__'; - if (pendingPermissionModeChangesRef.current.has(pendingKey)) return false; - if ( - mode === 'bypass' && - !(await toastApi.confirm({ - title: copy.bypassConfirmTitle, - description: copy.bypassConfirmDescription, - confirmLabel: copy.bypassConfirmLabel, - cancelLabel: copy.bypassCancelLabel, - destructive: true, - })) - ) { - return false; - } - - pendingPermissionModeChangesRef.current.add(pendingKey); - if (sessionId) - setPendingPermissionModeBySession((current) => ({ - ...current, - [sessionId]: true, - })); - try { - let nextMode = mode; - if (sessionId) { - const next = await window.maka.sessions.setPermissionMode(sessionId, mode); - nextMode = next.permissionMode === 'bypass' ? 'bypass' : 'ask'; - } else { - await setNewTaskPermissionMode(mode); - } - toastApi.success( - copy.permissionSwitched(copy.permissionLabels[nextMode]), - copy.permissionDescriptions[nextMode], - ); - if (sessionId) await refreshSessions(); - return nextMode === mode; - } catch (error) { - toastApi.error( - copy.permissionFailedTitle, - localizedShellErrorMessage(error, copy.permissionFallback, uiLocale), - undefined, - sessionId ? { sessionId } : undefined, - ); - return false; - } finally { - pendingPermissionModeChangesRef.current.delete(pendingKey); - if (sessionId) setPendingPermissionModeBySession((current) => omitSessionKey(current, sessionId)); - } - } - - return { - setPermissionMode, - }; -} diff --git a/apps/desktop/src/renderer/app-shell-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts index 27a066dc03..3de815dc8b 100644 --- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -33,7 +33,6 @@ export interface AppShellSessionUiState { shellRunUpdatesBySession: ShellRunUpdatesBySession; interactionBySession: InteractionQueues; messageQueueBySession: Record; - pendingPermissionModeBySession: Record; } // The pending plate keeps the Host revision beside its entries so edits can @@ -53,7 +52,6 @@ const SESSION_UI_MAP_KEYS = [ 'shellRunUpdatesBySession', 'interactionBySession', 'messageQueueBySession', - 'pendingPermissionModeBySession', ] as const satisfies readonly AppShellSessionUiStateMapKey[]; type MissingSessionUiMapKey = Exclude; @@ -63,7 +61,7 @@ void allSessionUiMapsAreListed; // An authoritative session-list refresh heals a session whose turn ended while // its SessionEvent stream wasn't being followed, and must drop only the live // projection. The independently-scoped maps (message load error / retry, pending -// permission-mode toggles, the permission queue, stop-pending) each have +// the permission queue, stop-pending) each have // their own lifecycle and must survive a mere turn settle — a full // `clearAppShellSessionUiStateForSession` (session deletion) would wipe them too. // Event-stream health is scoped the same way but lives outside this state; see @@ -182,7 +180,6 @@ export function createAppShellSessionUiStateController( setSessionEventHealthBySession: ((updater) => { sessionEventHealthBySessionRef.current = updater(sessionEventHealthBySessionRef.current); }) satisfies StateUpdater>, - setPendingPermissionModeBySession: createMapSetter('pendingPermissionModeBySession'), /** * The authority said something about `turnId` — it started, failed to * start, or ended. Drop that arm's `unconfirmed` claim so a session list @@ -243,7 +240,6 @@ export function useAppShellSessionUiState() { setInteractionBySession: controller.setInteractionBySession, setMessageQueueBySession: controller.setMessageQueueBySession, setSessionEventHealthBySession: controller.setSessionEventHealthBySession, - setPendingPermissionModeBySession: controller.setPendingPermissionModeBySession, confirmLiveTurn: controller.confirmLiveTurn, clearSessionUiState: controller.clearSessionUiState, clearTurnTransientStateIfCurrent: controller.clearTurnTransientStateIfCurrent, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index cb8fa9ae1c..8498cda53b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -37,6 +37,7 @@ import type { import type { SessionSummary } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { OrchestrationMode } from '@maka/core/orchestration'; +import type { PermissionMode } from '@maka/core/permission'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; @@ -190,7 +191,6 @@ import { } from './app-shell-revision-actions'; import { createAppShellSessionStartActions } from './app-shell-session-start-actions'; import { createAppShellSessionRowActions } from './app-shell-session-row-actions'; -import { createAppShellSessionSettingsActions } from './app-shell-session-settings-actions'; import { createAppShellStopAction } from './app-shell-stop-action'; import { useStableActions } from './use-stable-actions'; import { @@ -381,7 +381,6 @@ function AppShellContent({ setInteractionBySession, setMessageQueueBySession, setSessionEventHealthBySession, - setPendingPermissionModeBySession, } = useAppShellSessionWorkspace(toastApi); const interactionHydrationEpochRef = useRef(new Map()); const markInteractionChanged = useCallback((sessionId: string) => { @@ -476,7 +475,6 @@ function AppShellContent({ stopPendingBySession, interactionBySession, messageQueueBySession, - pendingPermissionModeBySession, streamingSessionIds, activeLiveTurnSnapshot, } = useAppShellSessionUiReads(sessionUiController, activeId); @@ -785,6 +783,64 @@ function AppShellContent({ ); }, }); + const permissionModeIntent = useSessionSettingIntent({ + catalogRevision, + write: async (sessionId, mode) => { + const summary = await window.maka.sessions.setPermissionMode(sessionId, mode); + return summary.permissionMode === mode; + }, + refreshCatalog: refreshSessions, + onWriteError: (sessionId, error) => { + if (activeIdRef.current !== sessionId) return; + showSessionError( + sessionId, + sessionSettingsCopy.permissionFailedTitle, + localizedShellErrorMessage( + error, + sessionSettingsCopy.permissionFallback, + uiLocale, + ), + ); + }, + }); + async function setPermissionMode(mode: PermissionMode): Promise { + if (mode !== 'ask' && mode !== 'bypass') return false; + const owner = captureComposerImportOwner(); + const sessionId = owner.sessionId; + const currentMode = sessionId + ? permissionModeIntent.overlayBySession[sessionId] + ?? sessionsRef.current.find((session) => session.id === sessionId)?.permissionMode + : newTaskPermissionMode; + if (currentMode === mode) return true; + if ( + mode === 'bypass' && + !(await toastApi.confirm({ + title: sessionSettingsCopy.bypassConfirmTitle, + description: sessionSettingsCopy.bypassConfirmDescription, + confirmLabel: sessionSettingsCopy.bypassConfirmLabel, + cancelLabel: sessionSettingsCopy.bypassCancelLabel, + destructive: true, + })) + ) { + return false; + } + if (!isComposerImportOwnerActive(owner)) return false; + if (sessionId) return permissionModeIntent.request(sessionId, mode); + try { + await setNewTaskPermissionMode(mode); + return true; + } catch (error) { + toastApi.error( + sessionSettingsCopy.permissionFailedTitle, + localizedShellErrorMessage( + error, + sessionSettingsCopy.permissionFallback, + uiLocale, + ), + ); + return false; + } + } const modelOverlay = activeSession ? modelIntent.overlayBySession[activeSession.id] : undefined; @@ -891,11 +947,10 @@ function AppShellContent({ // mask. Per @kenji PR109d review: pending state prevents double-click // duplicate sibling turns by disabling the action button between // click and `sessions:changed turn-status-change` arriving. - // The three de-dup registries (turn-footer actions, session-row actions, - // and per-session permission-mode changes) share the same keyed-Set - // shape; see useKeyedPendingRegistry. Only the turn-footer registry mirrors + // The two de-dup registries (turn-footer and session-row actions) share the + // same keyed-Set shape; see useKeyedPendingRegistry. Only the turn-footer registry mirrors // into React state (drives the disabled mask) and arms a 5s auto-clear - // fallback timer; the other two stay ref-only and clear in their action's + // fallback timer; the other stays ref-only and clears in its action's // `finally`. const turnActionRegistry = useKeyedPendingRegistry({ trackState: true, @@ -903,7 +958,6 @@ function AppShellContent({ }); const pendingTurnActions = turnActionRegistry.keys; const sessionRowActionRegistry = useKeyedPendingRegistry(); - const permissionModeChangeRegistry = useKeyedPendingRegistry(); const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) => `${sessionId}:${turnId}:${actionId}`; function omitSessionKey(current: Record, sessionId: string): Record { @@ -937,11 +991,11 @@ function AppShellContent({ function clearSessionRendererState(sessionId: string): void { clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); - permissionModeChangeRegistry.keysRef.current.delete(sessionId); planModeIntent.clear(sessionId); orchestrationModeIntent.clear(sessionId); modelIntent.clear(sessionId); thinkingLevelIntent.clear(sessionId); + permissionModeIntent.clear(sessionId); } const sessionRowActionHandlers = useStableActions(createAppShellSessionRowActions, { @@ -966,17 +1020,6 @@ function AppShellContent({ [], ); - const { setPermissionMode } = useStableActions(createAppShellSessionSettingsActions, { - uiLocale, - activeIdRef, - pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, - refreshSessions, - sessionsRef, - setNewTaskPermissionMode, - setPendingPermissionModeBySession, - toastApi, - }); - // Mode writes and catalog reads run on different clocks. These controllers // own that gap: latest intent wins, and a Host-committed value remains the // presentation overlay until a causally later successful catalog snapshot @@ -1303,7 +1346,10 @@ function AppShellContent({ activeExecutionBoundary, activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newTaskPermissionMode, ); - const activePermissionMode = activeBoundarySurface.permissionMode; + const activePermissionMode = activeId + ? permissionModeIntent.overlayBySession[activeId] + ?? activeBoundarySurface.permissionMode + : activeBoundarySurface.permissionMode; const planMode = usePlanModeState(activeSessionForView); const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({ id: proposal.proposalId, @@ -2294,7 +2340,6 @@ function AppShellContent({ handleConnectionEvent, openHelp, openSettings, - pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, pendingTurnActionTimersRef: turnActionRegistry.timersRef, pendingTurnActionsRef: turnActionRegistry.keysRef, projectPickerPendingRef, @@ -3012,7 +3057,6 @@ function AppShellContent({ taskSubmissionHardBlocked } permissionMode={activePermissionMode} - permissionModePending={activeId ? pendingPermissionModeBySession[activeId] === true : false} // Every "cannot change this mid-turn" gate reads `turnActive`, // the same witness Stop reads. Reading the persisted status // here instead left these toggles live through the whole @@ -3020,15 +3064,13 @@ function AppShellContent({ // mode change to land before the run registers and alter the // execution config of the turn already sent. permissionModeDisabledReason={ - activeId && pendingPermissionModeBySession[activeId] === true - ? shellCopy.permissionModeChanging - : activeStreamingLive - ? shellCopy.permissionModeStreaming - : activeId && turnActive - ? shellCopy.permissionModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.permissionModeWaiting - : undefined + activeStreamingLive + ? shellCopy.permissionModeStreaming + : activeId && turnActive + ? shellCopy.permissionModeRunning + : activeId && activeSessionForView?.status === 'waiting_for_user' + ? shellCopy.permissionModeWaiting + : undefined } onPermissionModeChange={ activeBoundarySurface.localInteractionAvailable diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 05e6e3bc07..1c9f4a9ee6 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -288,9 +288,12 @@ export function QuoteCompanionPanel(props: { // (the companion has no independent picker; it inherits the source model). modelLabel={activeModelLabel} permissionMode={companion.permissionMode} - permissionModePending={companion.permissionModePending} permissionModeDisabledReason={ - companion.streaming ? copy.permissionStreaming : undefined + companion.permissionModePending + ? copy.permissionChanging + : companion.streaming + ? copy.permissionStreaming + : undefined } onPermissionModeChange={(mode) => { void companion.setPermissionMode(mode); diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 14fe0721d6..1070b1adcc 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -293,6 +293,7 @@ export interface DesktopConversationCopy { namePrefix: string; /** Short-lived status while the eager fork is created. */ preparing: string; + permissionChanging: string; permissionStreaming: string; scrollToBottom: string; closeConfirmation: { @@ -598,6 +599,7 @@ const COPY = { defaultName: '侧边对话', namePrefix: '侧聊:', preparing: '正在建立侧边对话…', + permissionChanging: '权限模式正在切换,请稍候', permissionStreaming: '侧边对话运行中暂时不能更改权限', scrollToBottom: '滚动侧边对话到底部', closeConfirmation: { @@ -828,6 +830,7 @@ const COPY = { defaultName: 'Side chat', namePrefix: 'Side: ', preparing: 'Preparing side chat…', + permissionChanging: 'Permission mode is changing', permissionStreaming: 'Permissions cannot change while the side chat is running', scrollToBottom: 'Scroll side conversation to bottom', closeConfirmation: { diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index b0f5076b59..724c8c2dee 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -23,7 +23,7 @@ import { type UiCatalog, type UiLocale } from '@maka/core/ui-locale'; import { type PermissionMode } from '@maka/core/permission'; -import { type ChatDefaultPermissionMode, type SettingsSection } from '@maka/core/settings'; +import { type SettingsSection } from '@maka/core/settings'; import { type SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; @@ -323,13 +323,10 @@ type ShellCopy = { runtimeFailures: Record<'not_found' | 'blocked_path' | 'state_error' | 'write_failed', string>; }; sessionSettingsActions: { - permissionLabels: Record; - permissionDescriptions: Record; bypassConfirmTitle: string; bypassConfirmDescription: string; bypassConfirmLabel: string; bypassCancelLabel: string; - permissionSwitched(label: string): string; permissionFailedTitle: string; permissionFallback: string; modelFailedTitle: string; @@ -974,21 +971,11 @@ const SHELL_COPY_BY_LOCALE = { }, }, sessionSettingsActions: { - permissionLabels: { - ask: '自动', - bypass: '完全权限', - }, - permissionDescriptions: { - explore: '只读:只读取和搜索,写入文件和访问网络会先来问你。', - ask: '自动:在 Maka 的保护层内执行;需要超出当前权限范围时会先来问你。', - bypass: '本地工具直接访问你的文件和网络,不经 Maka 的保护层。', - }, bypassConfirmTitle: '切换到完全权限?', bypassConfirmDescription: '本地工具将直接读写你的文件并访问网络,不经 Maka 的保护层。仅用于你完全信任、或已在外部隔离环境中运行的任务。', bypassConfirmLabel: '开启完全权限', bypassCancelLabel: '保持自动', - permissionSwitched: (label: string) => `已切到 ${label}`, permissionFailedTitle: '切换权限模式失败', permissionFallback: '权限模式暂时无法切换,请稍后重试。', modelFailedTitle: '切换模型失败', @@ -1487,21 +1474,11 @@ const SHELL_COPY_BY_LOCALE = { }, }, sessionSettingsActions: { - permissionLabels: { - ask: 'Auto', - bypass: 'Full access', - }, - permissionDescriptions: { - explore: 'Read only: reads and searches only; writing files and network access ask you first.', - ask: "Auto: runs inside Maka's protection layer and asks before anything goes beyond the current permissions.", - bypass: "Local tools reach your files and your network directly, outside Maka's protection layer.", - }, bypassConfirmTitle: 'Switch to full access?', bypassConfirmDescription: "Local tools will read and write your files and reach the network directly, outside Maka's protection layer. Use only for tasks you fully trust, or ones already isolated by their environment.", bypassConfirmLabel: 'Turn on full access', bypassCancelLabel: 'Keep Auto', - permissionSwitched: (label: string) => `Switched to ${label}`, permissionFailedTitle: 'Could not change permission mode', permissionFallback: 'The permission mode could not be changed. Try again later.', modelFailedTitle: 'Could not change model', diff --git a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts index 416673b166..4b03002af2 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts @@ -37,7 +37,6 @@ const selectMessageRetryPending = (state: AppShellSessionUiState) => state.messa const selectStopPending = (state: AppShellSessionUiState) => state.stopPendingBySession; const selectInteraction = (state: AppShellSessionUiState) => state.interactionBySession; const selectMessageQueue = (state: AppShellSessionUiState) => state.messageQueueBySession; -const selectPendingPermissionMode = (state: AppShellSessionUiState) => state.pendingPermissionModeBySession; const selectPulseSet = (state: AppShellSessionUiState) => selectStreamingSessionIds(state.liveTurnBySession); /** @@ -76,7 +75,6 @@ export function useAppShellSessionUiReads( stopPendingBySession: Record; interactionBySession: InteractionQueues; messageQueueBySession: Record; - pendingPermissionModeBySession: Record; streamingSessionIds: Set; activeLiveTurnSnapshot: LiveTurnSnapshot; } { @@ -86,7 +84,6 @@ export function useAppShellSessionUiReads( stopPendingBySession: useAppShellSessionUiSelector(controller, selectStopPending), interactionBySession: useAppShellSessionUiSelector(controller, selectInteraction), messageQueueBySession: useAppShellSessionUiSelector(controller, selectMessageQueue), - pendingPermissionModeBySession: useAppShellSessionUiSelector(controller, selectPendingPermissionMode), streamingSessionIds: useAppShellSessionUiSelector(controller, selectPulseSet, undefined, sessionIdSetsEqual), activeLiveTurnSnapshot: useAppShellSessionUiSelector( controller, diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index 25e3c74609..a9aeb8e258 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -112,7 +112,6 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { setInteractionBySession: sessionUi.setInteractionBySession, setMessageQueueBySession: sessionUi.setMessageQueueBySession, setSessionEventHealthBySession: sessionUi.setSessionEventHealthBySession, - setPendingPermissionModeBySession: sessionUi.setPendingPermissionModeBySession, confirmLiveTurn: sessionUi.confirmLiveTurn, }; } diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 26402a5ab0..1af51e936d 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -374,9 +374,8 @@ export const Composer = forwardRef< * 完全权限); selecting an option fires `onPermissionModeChange`. * A read-only session displays 只读 without it becoming a third * option (#1611). - */ + */ permissionMode?: PermissionMode; - permissionModePending?: boolean; permissionModeDisabledReason?: string; onPermissionModeChange?(mode: PermissionMode): void | Promise; /** @@ -1901,7 +1900,6 @@ export const Composer = forwardRef< }} disabled={ props.disabled - || props.permissionModePending === true || Boolean(props.permissionModeDisabledReason) } disabledReason={props.permissionModeDisabledReason} From 7bfcde521f21e9ec0afa84d7466bd8bf03223220 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:10:40 +0800 Subject: [PATCH 11/24] fix(ui): expose selected model settings accessibly Generated-by: OpenAI Codex --- .../__tests__/chat-model-switcher.test.tsx | 57 ++++++++++++- packages/ui/src/chat-model-switcher.tsx | 79 ++++++++++++------- 2 files changed, 106 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/__tests__/chat-model-switcher.test.tsx b/packages/ui/src/__tests__/chat-model-switcher.test.tsx index d8e99598d0..8b5ff346d5 100644 --- a/packages/ui/src/__tests__/chat-model-switcher.test.tsx +++ b/packages/ui/src/__tests__/chat-model-switcher.test.tsx @@ -24,7 +24,10 @@ import type { SessionSummary } from '@maka/core/session'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import { ChatModelSwitcher } from '../chat-model-switcher.js'; +import { + ChatModelSwitcher, + ThinkingLevelSelector, +} from '../chat-model-switcher.js'; import { LocaleProvider } from '../locale-context.js'; const originalGlobals = { @@ -82,7 +85,17 @@ test('can revert an optimistic cross-connection switch between same-named models ); }); - const revertRow = [...document.querySelectorAll('[role="menuitem"]')] + const trigger = document.querySelector('.maka-model-switcher-trigger'); + assert.equal( + trigger?.getAttribute('aria-label'), + 'Switch model for this task: Shared model', + ); + const selected = document.querySelector( + '[role="menuitemradio"][aria-checked="true"]', + ); + assert.match(selected?.textContent ?? '', /Connection B model/); + + const revertRow = [...document.querySelectorAll('[role="menuitemradio"]')] .find((row) => row.textContent?.includes('Connection A model')); assert.ok(revertRow, 'missing Connection A model option'); await act(() => { @@ -95,6 +108,46 @@ test('can revert an optimistic cross-connection switch between same-named models }]); }); +test('exposes the current thinking level as radio state', async () => { + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => ({ + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + root = createRoot(container); + + await act(() => { + root?.render( + + {}} + /> + , + ); + }); + + const trigger = document.querySelector('.maka-thinking-level-selector'); + assert.match(trigger?.getAttribute('aria-label') ?? '', /High/); + const selected = document.querySelectorAll( + '[role="menuitemradio"][aria-checked="true"]', + ); + assert.equal(selected.length, 1); + assert.match(selected[0]?.textContent ?? '', /High/); +}); + const choices: ChatModelChoice[] = [ { connectionSlug: 'connection-a', diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx index 694348755c..0abe506a8d 100644 --- a/packages/ui/src/chat-model-switcher.tsx +++ b/packages/ui/src/chat-model-switcher.tsx @@ -38,7 +38,11 @@ import { type ReactNode, useMemo, useState } from 'react'; import { Button as UiButton } from '@astryxdesign/core'; -import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu'; +import { + DropdownMenu, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, +} from '@astryxdesign/core/DropdownMenu'; import { ICON_SIZE, AlertTriangle, Check, Settings } from './icons.js'; import { type ChatModelChoice, @@ -72,15 +76,16 @@ const currentCheck =