From e67fbb7c335368117b164bc91e42e6d6d14d5d1d Mon Sep 17 00:00:00 2001 From: Amal Dev Date: Sun, 30 Aug 2026 11:33:22 -0400 Subject: [PATCH] fix(desktop): make model and thinking-level changes instant without success toasts Replaces the shared boolean pending-Set gate with independent optimistic-value overlays per session for model, thinking-level, and permission-mode, so a change applies instantly, model and thinking writes never block each other, rapid changes coalesce to the latest one, and success is silent (only a terminal failure rolls back and shows a toast). Fixes #3745 Generated-by: Claude --- ...app-shell-session-settings-actions.test.ts | 236 +++++++++--------- .../app-shell-session-ui-state.test.ts | 13 +- .../desktop/src/renderer/app-shell-effects.ts | 4 - .../app-shell-session-settings-actions.ts | 189 ++++++-------- .../renderer/app-shell-session-ui-state.ts | 35 ++- apps/desktop/src/renderer/app-shell.tsx | 69 +++-- .../use-app-shell-session-ui-reads.ts | 22 +- .../use-app-shell-session-workspace.ts | 5 +- .../src/renderer/use-shell-chat-model.ts | 34 ++- packages/ui/src/chat-model-switcher.tsx | 2 +- packages/ui/src/chat-view.tsx | 1 - packages/ui/src/composer.tsx | 7 +- 12 files changed, 306 insertions(+), 311 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 63ff7460d0..1bc37c2a99 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,8 @@ 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 { PermissionMode } from '@maka/core/permission'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; import { createAppShellSessionSettingsActions } from '../../renderer/app-shell-session-settings-actions.js'; @@ -55,26 +55,29 @@ function session(id: string): DesktopSessionSummary { }; } +type ModelValue = { llmConnectionSlug: string; model: string }; + function createHarness(options: { confirm?: () => Promise; - connections?: LlmConnection[]; - messages?: StoredMessage[]; permissionModeResult?: 'ask' | 'bypass'; } = {}) { 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 optimisticState = { + optimisticPermissionModeBySession: {} as Record, + optimisticSessionModelBySession: {} as Record, + optimisticSessionThinkingLevelBySession: {} as Record, + }; const modelCalls: string[] = []; - const permissionCalls: string[] = []; + const modelDeferreds: Array>> = []; const thinkingCalls: string[] = []; + const thinkingDeferreds: Array>> = []; + const permissionCalls: string[] = []; const errors: string[] = []; const errorTargets: Array<{ sessionId: string } | undefined> = []; - const successes: Array<{ title: string; description?: string }> = []; const newTaskPermissionModes: string[] = []; - const modelResult = deferred(); - const thinkingResult = deferred(); + const composerDefaults: ModelValue[] = []; Object.defineProperty(globalThis, 'window', { configurable: true, @@ -90,11 +93,15 @@ function createHarness(options: { }, setModel: async (sessionId: string) => { modelCalls.push(sessionId); - return modelResult.promise; + const d = deferred(); + modelDeferreds.push(d); + return d.promise; }, setThinkingLevel: async (sessionId: string) => { thinkingCalls.push(sessionId); - return thinkingResult.promise; + const d = deferred(); + thinkingDeferreds.push(d); + return d.promise; }, }, }, @@ -104,25 +111,26 @@ 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 }, + getOptimisticState: () => optimisticState, refreshSessions: async () => sessions, - saveComposerDefaults: () => undefined, + saveComposerDefaults: (patch) => composerDefaults.push(patch.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); + setOptimisticPermissionModeBySession: (update) => { + optimisticState.optimisticPermissionModeBySession = update(optimisticState.optimisticPermissionModeBySession); + }, + setOptimisticSessionModelBySession: (update) => { + optimisticState.optimisticSessionModelBySession = update(optimisticState.optimisticSessionModelBySession); + }, + setOptimisticSessionThinkingLevelBySession: (update) => { + optimisticState.optimisticSessionThinkingLevelBySession = update( + optimisticState.optimisticSessionThinkingLevelBySession, + ); }, setSessions: (update) => { sessionsRef.current = update(sessionsRef.current); }, toastApi: { - success: (title, description) => successes.push({ title, description }), error: (title, _description, _details, target) => { errors.push(title); errorTargets.push(target); @@ -134,18 +142,17 @@ function createHarness(options: { return { actions, activeIdRef, + composerDefaults, errors, errorTargets, modelCalls, - modelResult, + modelDeferreds, newTaskPermissionModes, - pending, - pendingBySession, + optimisticState, permissionCalls, sessionsRef, thinkingCalls, - thinkingResult, - successes, + thinkingDeferreds, }; } @@ -175,15 +182,18 @@ describe('AppShell session settings actions', () => { assert.equal(switched, false); assert.equal(confirmations, 1); assert.deepEqual(harness.permissionCalls, []); + assert.equal(harness.optimisticState.optimisticPermissionModeBySession['session-a'], undefined); }); - it('reports a confirmed bypass switch as successful', async () => { + it('commits a confirmed bypass switch and clears its overlay afterward', async () => { const harness = createHarness(); const switched = await harness.actions.setPermissionMode('bypass'); assert.equal(switched, true); assert.deepEqual(harness.permissionCalls, ['session-a:bypass']); + assert.equal(harness.optimisticState.optimisticPermissionModeBySession['session-a'], undefined); + assert.equal(harness.sessionsRef.current.find((s) => s.id === 'session-a')?.permissionMode, 'bypass'); }); it('does not report success when the Host returns another permission mode', async () => { @@ -215,143 +225,133 @@ describe('AppShell session settings actions', () => { assert.deepEqual(harness.permissionCalls, []); }); - it('blocks a thinking-level mutation while the same session model mutation is pending', async () => { + it('applies a model change optimistically and clears the overlay once committed', async () => { const harness = createHarness(); - const modelChange = harness.actions.setSessionModel({ + const modelChange = harness.actions.setSessionModel({ llmConnectionSlug: 'e2e', model: 'claude-opus' }); + assert.deepEqual(harness.optimisticState.optimisticSessionModelBySession['session-a'], { 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')); + harness.modelDeferreds[0].resolve({ ...session('session-a'), model: 'claude-opus' }); await modelChange; + + assert.equal(harness.optimisticState.optimisticSessionModelBySession['session-a'], undefined); + assert.equal(harness.sessionsRef.current.find((s) => s.id === 'session-a')?.model, 'claude-opus'); + assert.deepEqual(harness.composerDefaults, [{ llmConnectionSlug: 'e2e', model: 'claude-opus' }]); }); - 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('rolls back the overlay and shows one error on a terminal failure', async () => { + const harness = createHarness(); - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - harness.modelResult.resolve({ ...session('session-a'), model: 'claude-opus' }); + const modelChange = harness.actions.setSessionModel({ llmConnectionSlug: 'e2e', model: 'claude-opus' }); + harness.modelDeferreds[0].reject(new Error('fixture failure')); await modelChange; - assert.deepEqual(harness.successes, [ - { - title: '已切换当前任务模型', - description: 'claude-haiku → claude-opus', - }, - ]); + assert.equal(harness.optimisticState.optimisticSessionModelBySession['session-a'], undefined); + assert.equal(harness.errors.length, 1); + assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); + assert.deepEqual(harness.composerDefaults, []); + assert.equal(harness.sessionsRef.current.find((s) => s.id === 'session-a')?.model, 'claude-sonnet'); }); - it('falls back to the configured model for a fresh conversation', async () => { + it('lets a newer model selection win over a slower in-flight one (latest wins)', async () => { const harness = createHarness(); - const modelChange = harness.actions.setSessionModel({ + const firstChange = harness.actions.setSessionModel({ llmConnectionSlug: 'e2e', model: 'claude-opus' }); + const secondChange = harness.actions.setSessionModel({ llmConnectionSlug: 'e2e', model: 'claude-haiku' }); + assert.deepEqual(harness.modelCalls, ['session-a', 'session-a']); + assert.deepEqual(harness.optimisticState.optimisticSessionModelBySession['session-a'], { llmConnectionSlug: 'e2e', - model: 'claude-opus', + model: 'claude-haiku', }); - harness.modelResult.resolve({ ...session('session-a'), model: 'claude-opus' }); - await modelChange; - assert.equal(harness.successes[0]?.description, 'claude-sonnet → claude-opus'); - }); + harness.modelDeferreds[0].resolve({ ...session('session-a'), model: 'claude-opus' }); + await firstChange; - 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[], + assert.equal(harness.sessionsRef.current.find((s) => s.id === 'session-a')?.model, 'claude-sonnet'); + assert.equal(harness.errors.length, 0); + assert.deepEqual(harness.optimisticState.optimisticSessionModelBySession['session-a'], { + llmConnectionSlug: 'e2e', + model: 'claude-haiku', }); - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'relay', - model: 'claude-sonnet', - }); - harness.modelResult.resolve({ - ...session('session-a'), - llmConnectionSlug: 'relay', - }); - await modelChange; + harness.modelDeferreds[1].resolve({ ...session('session-a'), model: 'claude-haiku' }); + await secondChange; - assert.equal( - harness.successes[0]?.description, - 'claude-sonnet (Primary) → claude-sonnet (Relay)', - ); + assert.equal(harness.sessionsRef.current.find((s) => s.id === 'session-a')?.model, 'claude-haiku'); + assert.equal(harness.optimisticState.optimisticSessionModelBySession['session-a'], undefined); }); - it('keeps another session available while the first session mutation is pending', async () => { + it('does not roll back a newer selection when a superseded call fails', async () => { const harness = createHarness(); - const modelChange = harness.actions.setSessionModel({ + const firstChange = harness.actions.setSessionModel({ llmConnectionSlug: 'e2e', model: 'claude-opus' }); + const secondChange = harness.actions.setSessionModel({ llmConnectionSlug: 'e2e', model: 'claude-haiku' }); + + harness.modelDeferreds[0].reject(new Error('stale failure')); + await firstChange; + + assert.deepEqual(harness.optimisticState.optimisticSessionModelBySession['session-a'], { llmConnectionSlug: 'e2e', - model: 'claude-opus', + model: 'claude-haiku', }); - 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'])); + assert.equal(harness.errors.length, 0); - harness.thinkingResult.resolve(session('session-b')); - await thinkingChange; - harness.modelResult.resolve(session('session-a')); - await modelChange; + harness.modelDeferreds[1].resolve({ ...session('session-a'), model: 'claude-haiku' }); + await secondChange; + assert.equal(harness.sessionsRef.current.find((s) => s.id === 'session-a')?.model, 'claude-haiku'); }); - it('blocks a model mutation while the same session thinking mutation is pending', async () => { + it('model and thinking-level writes for the same session do not block each other', async () => { const harness = createHarness(); + const modelChange = harness.actions.setSessionModel({ llmConnectionSlug: 'e2e', model: 'claude-opus' }); const thinkingChange = harness.actions.setSessionThinkingLevel('high'); - await harness.actions.setSessionModel({ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); + assert.deepEqual(harness.modelCalls, ['session-a']); assert.deepEqual(harness.thinkingCalls, ['session-a']); - assert.deepEqual(harness.modelCalls, []); - assert.equal(harness.pendingBySession['session-a'], true); - harness.thinkingResult.resolve(session('session-a')); - await thinkingChange; - assert.equal(harness.pendingBySession['session-a'], undefined); + harness.modelDeferreds[0].resolve({ ...session('session-a'), model: 'claude-opus' }); + harness.thinkingDeferreds[0].resolve({ ...session('session-a'), thinkingLevel: 'high' }); + await Promise.all([modelChange, thinkingChange]); }); - it('releases the session owner after a failed mutation so the next action can run', async () => { + it('records an explicit "use model default" thinking-level choice distinctly from no override', async () => { const harness = createHarness(); + // Must start on a concrete level, or the "already at this value" guard skips. + harness.sessionsRef.current = harness.sessionsRef.current.map((s) => ( + s.id === 'session-a' ? { ...s, thinkingLevel: 'high' } : s + )) as typeof harness.sessionsRef.current; - const thinkingChange = harness.actions.setSessionThinkingLevel('high'); - harness.thinkingResult.reject(new Error('fixture failure')); + const thinkingChange = harness.actions.setSessionThinkingLevel(undefined); + assert.equal('session-a' in harness.optimisticState.optimisticSessionThinkingLevelBySession, true); + assert.equal(harness.optimisticState.optimisticSessionThinkingLevelBySession['session-a'], undefined); + + harness.thinkingDeferreds[0].resolve({ ...session('session-a'), thinkingLevel: undefined }); await thinkingChange; + assert.equal('session-a' in harness.optimisticState.optimisticSessionThinkingLevelBySession, false); + }); - assert.equal(harness.pending.has('session-a'), false); - assert.equal(harness.pendingBySession['session-a'], undefined); - assert.equal(harness.errors.length, 1); - assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); + it('keeps two sessions fully independent', 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'); - const modelChange = harness.actions.setSessionModel({ + assert.deepEqual(harness.modelCalls, ['session-a']); + assert.deepEqual(harness.thinkingCalls, ['session-b']); + assert.deepEqual(harness.optimisticState.optimisticSessionModelBySession['session-a'], { llmConnectionSlug: 'e2e', model: 'claude-opus', }); - assert.deepEqual(harness.modelCalls, ['session-a']); - harness.modelResult.resolve(session('session-a')); + assert.equal(harness.optimisticState.optimisticSessionThinkingLevelBySession['session-b'], 'high'); + + harness.thinkingDeferreds[0].resolve(session('session-b')); + await thinkingChange; + harness.modelDeferreds[0].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 0ae90c179b..ec0f7c5b82 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 @@ -74,8 +74,12 @@ function seededState(): AppShellSessionUiState { drop: [boundaryRequest('drop')], keep: [boundaryRequest('keep')], }, - pendingPermissionModeBySession: { drop: true, keep: true }, - pendingSessionModelBySession: { drop: true, keep: true }, + optimisticPermissionModeBySession: { drop: 'bypass', keep: 'bypass' }, + optimisticSessionModelBySession: { + drop: { llmConnectionSlug: 'e2e', model: 'claude-opus' }, + keep: { llmConnectionSlug: 'e2e', model: 'claude-opus' }, + }, + optimisticSessionThinkingLevelBySession: { drop: 'high', keep: 'high' }, }; } @@ -233,8 +237,9 @@ 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']); - assert.deepEqual(Object.keys(next.pendingSessionModelBySession), ['keep']); + assert.deepEqual(Object.keys(next.optimisticPermissionModeBySession), ['keep']); + assert.deepEqual(Object.keys(next.optimisticSessionModelBySession), ['keep']); + assert.deepEqual(Object.keys(next.optimisticSessionThinkingLevelBySession), ['keep']); }); it('keeps state identity for no-op map updates and only replaces the selected map', () => { diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index a63f6a7138..00bc4edea7 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -187,8 +187,6 @@ export function useAppShellBootstrapSubscriptions(options: { handleConnectionEvent: (event: ConnectionEvent) => void; openHelp: () => void; openSettings: () => void; - pendingPermissionModeChangesRef: RefBox>; - pendingSessionModelChangesRef: RefBox>; pendingTurnActionTimersRef: RefBox>>; pendingTurnActionsRef: RefBox>; projectPickerPendingRef: RefBox; @@ -351,8 +349,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 0c4f37d782..80cc9cf617 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -18,22 +18,17 @@ */ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { LlmConnection } from '@maka/core/llm-connections'; import type { PermissionMode } from '@maka/core/permission'; -import { - deriveModelSwitchTranscript, - 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'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; +import type { NewChatModel } from './shell-chat-model-selection.js'; type RefBox = { current: T }; -type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; +type RecordUpdater = (updater: (current: Record) => Record) => void; type ToastApi = { - success(title: string, description?: string): void; error( title: string, description?: string, @@ -49,28 +44,47 @@ type ToastApi = { }): Promise; }; +/** The three optimistic overlays this file reads back synchronously to detect + * a superseded request — sourced from `AppShellSessionUiState`. */ +type OptimisticSettingsState = { + optimisticPermissionModeBySession: Record; + optimisticSessionModelBySession: Record; + optimisticSessionThinkingLevelBySession: Record; +}; + export interface AppShellSessionSettingsActions { setPermissionMode(mode: PermissionMode): Promise; - setSessionModel(input: { llmConnectionSlug: string; model: string }): Promise; + setSessionModel(input: NewChatModel): Promise; setSessionThinkingLevel(level: ThinkingLevel | undefined): Promise; } +function omitSessionKey(current: Record, sessionId: string): Record { + if (!(sessionId in current)) return current; + const next = { ...current }; + delete next[sessionId]; + return next; +} + +/** True while `sessionId`'s overlay still holds the exact value this call + * requested — false once a newer call has overwritten or cleared it. Uses + * `in` (not truthiness) so a thinking-level request for `undefined` isn't + * mistaken for an absent override. */ +function isStillLatest(map: Record, sessionId: string, value: T): boolean { + return sessionId in map && map[sessionId] === value; +} + export function createAppShellSessionSettingsActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; - connections: readonly LlmConnection[]; - messages: readonly StoredMessage[]; - pendingPermissionModeChangesRef: RefBox>; - pendingSessionModelChangesRef: RefBox>; + getOptimisticState: () => OptimisticSettingsState; refreshSessions: () => Promise; - saveComposerDefaults: (patch: { - model: { llmConnectionSlug: string; model: string }; - }) => void; + saveComposerDefaults: (patch: { model: NewChatModel }) => void; sessionsRef: RefBox; /** Persists the chat default; awaited so a failure surfaces as one. */ setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void | Promise; - setPendingPermissionModeBySession: BooleanRecordUpdater; - setPendingSessionModelBySession: BooleanRecordUpdater; + setOptimisticPermissionModeBySession: RecordUpdater; + setOptimisticSessionModelBySession: RecordUpdater; + setOptimisticSessionThinkingLevelBySession: RecordUpdater; setSessions: ( updater: (current: DesktopSessionSummary[]) => DesktopSessionSummary[], ) => void; @@ -79,41 +93,19 @@ export function createAppShellSessionSettingsActions(deps: { const { uiLocale, activeIdRef, - connections, - messages, - pendingPermissionModeChangesRef, - pendingSessionModelChangesRef, + getOptimisticState, refreshSessions, saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, + setOptimisticPermissionModeBySession, + setOptimisticSessionModelBySession, + setOptimisticSessionThinkingLevelBySession, setSessions, 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; - } - - 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; @@ -121,8 +113,6 @@ export function createAppShellSessionSettingsActions(deps: { ? 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({ @@ -136,78 +126,63 @@ export function createAppShellSessionSettingsActions(deps: { 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'; - setSessions((prev) => - prev.map((session) => (session.id === sessionId ? next : session)), - ); - } else { + if (!sessionId) { + // No active task — this is the chat-default permission mode, which has + // no per-session overlay to manage. + try { await setNewTaskPermissionMode(mode); + return true; + } catch (error) { + toastApi.error( + copy.permissionFailedTitle, + localizedShellErrorMessage(error, copy.permissionFallback, uiLocale), + ); + return false; } - toastApi.success( - copy.permissionSwitched(copy.permissionLabels[nextMode]), - copy.permissionDescriptions[nextMode], + } + + setOptimisticPermissionModeBySession((current) => ({ ...current, [sessionId]: mode })); + try { + const next = await window.maka.sessions.setPermissionMode(sessionId, mode); + const nextMode = next.permissionMode === 'bypass' ? 'bypass' : 'ask'; + if (!isStillLatest(getOptimisticState().optimisticPermissionModeBySession, sessionId, mode)) { + return nextMode === mode; + } + setSessions((prev) => + prev.map((session) => (session.id === sessionId ? next : session)), ); - if (sessionId) await refreshSessions(); + setOptimisticPermissionModeBySession((current) => omitSessionKey(current, sessionId)); + await refreshSessions(); return nextMode === mode; } catch (error) { + if (!isStillLatest(getOptimisticState().optimisticPermissionModeBySession, sessionId, mode)) { + return false; + } + setOptimisticPermissionModeBySession((current) => omitSessionKey(current, sessionId)); toastApi.error( copy.permissionFailedTitle, localizedShellErrorMessage(error, copy.permissionFallback, uiLocale), undefined, - sessionId ? { sessionId } : undefined, + { sessionId }, ); return false; - } finally { - pendingPermissionModeChangesRef.current.delete(pendingKey); - if (sessionId) setPendingPermissionModeBySession((current) => omitSessionKey(current, sessionId)); } } - async function setSessionModel(input: { llmConnectionSlug: string; model: string }) { + async function setSessionModel(input: NewChatModel) { const sessionId = activeIdRef.current; if (!sessionId) return; - const previous = sessionsRef.current.find((session) => session.id === sessionId); - const transcript = deriveModelSwitchTranscript(messages); - if (pendingSessionModelChangesRef.current.has(sessionId)) return; - pendingSessionModelChangesRef.current.add(sessionId); - setPendingSessionModelBySession((current) => ({ - ...current, - [sessionId]: true, - })); + setOptimisticSessionModelBySession((current) => ({ ...current, [sessionId]: input })); try { const next = await window.maka.sessions.setModel(sessionId, input); + if (!isStillLatest(getOptimisticState().optimisticSessionModelBySession, sessionId, input)) return; setSessions((prev) => prev.map((session) => (session.id === next.id ? next : session))); - if (activeIdRef.current === sessionId) { - const connectionChanged = previous?.llmConnectionSlug !== next.llmConnectionSlug; - const to = modelEndpointLabel(next.llmConnectionSlug, next.model, connectionChanged); - const previousModel = transcript.lastUsedModel ?? previous?.model; - toastApi.success( - copy.modelSwitchedTitle, - previous && previousModel - ? copy.modelSwitchedDescription( - modelEndpointLabel( - previous.llmConnectionSlug, - previousModel, - connectionChanged, - ), - to, - ) - : to, - ); - } saveComposerDefaults({ model: input }); + setOptimisticSessionModelBySession((current) => omitSessionKey(current, sessionId)); await refreshSessions(); } catch (error) { + if (!isStillLatest(getOptimisticState().optimisticSessionModelBySession, sessionId, input)) return; + setOptimisticSessionModelBySession((current) => omitSessionKey(current, sessionId)); if (activeIdRef.current === sessionId) { toastApi.error( copy.modelFailedTitle, @@ -216,9 +191,6 @@ export function createAppShellSessionSettingsActions(deps: { { sessionId }, ); } - } finally { - pendingSessionModelChangesRef.current.delete(sessionId); - setPendingSessionModelBySession((current) => omitSessionKey(current, sessionId)); } } @@ -227,20 +199,20 @@ export function createAppShellSessionSettingsActions(deps: { 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, - })); + setOptimisticSessionThinkingLevelBySession((currentPending) => ({ ...currentPending, [sessionId]: level })); try { const next = await window.maka.sessions.setThinkingLevel(sessionId, level); - setSessions((prev) => prev.map((session) => (session.id === next.id ? next : session))); - if (activeIdRef.current === sessionId) { - toastApi.success(copy.thinkingUpdatedTitle, level ? copy.thinkingLabels[level] : copy.thinkingDefault); + if (!isStillLatest(getOptimisticState().optimisticSessionThinkingLevelBySession, sessionId, level)) { + return; } + setSessions((prev) => prev.map((session) => (session.id === next.id ? next : session))); + setOptimisticSessionThinkingLevelBySession((currentPending) => omitSessionKey(currentPending, sessionId)); await refreshSessions(); } catch (error) { + if (!isStillLatest(getOptimisticState().optimisticSessionThinkingLevelBySession, sessionId, level)) { + return; + } + setOptimisticSessionThinkingLevelBySession((currentPending) => omitSessionKey(currentPending, sessionId)); if (activeIdRef.current === sessionId) { toastApi.error( copy.thinkingFailedTitle, @@ -249,9 +221,6 @@ export function createAppShellSessionSettingsActions(deps: { { sessionId }, ); } - } finally { - pendingSessionModelChangesRef.current.delete(sessionId); - setPendingSessionModelBySession((currentPending) => omitSessionKey(currentPending, sessionId)); } } 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 12b163ac2f..41dadae1eb 100644 --- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -20,8 +20,11 @@ import { useRef } from 'react'; import type { MessageQueueEntryProjection } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; +import type { PermissionMode } from '@maka/core/permission'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; import { confirmLiveTurn, type InteractionQueues, type LiveTurnProjection } from '@maka/ui'; import type { ShellRunUpdatesBySession } from './shell-run-update-state.js'; +import type { NewChatModel } from './shell-chat-model-selection.js'; type StateUpdater = (updater: (current: T) => T) => void; @@ -33,8 +36,12 @@ export interface AppShellSessionUiState { shellRunUpdatesBySession: ShellRunUpdatesBySession; interactionBySession: InteractionQueues; messageQueueBySession: Record; - pendingPermissionModeBySession: Record; - pendingSessionModelBySession: Record; + /** Optimistic value for an in-flight change, keyed by session. Absent key + * means "no override"; check with `in`, not truthiness — a thinking-level + * override can itself be `undefined`. */ + optimisticPermissionModeBySession: Record; + optimisticSessionModelBySession: Record; + optimisticSessionThinkingLevelBySession: Record; } // The pending plate mirrors the Host's follow-up queue only: steering entries @@ -51,8 +58,9 @@ const SESSION_UI_MAP_KEYS = [ 'shellRunUpdatesBySession', 'interactionBySession', 'messageQueueBySession', - 'pendingPermissionModeBySession', - 'pendingSessionModelBySession', + 'optimisticPermissionModeBySession', + 'optimisticSessionModelBySession', + 'optimisticSessionThinkingLevelBySession', ] as const satisfies readonly AppShellSessionUiStateMapKey[]; type MissingSessionUiMapKey = Exclude; @@ -61,10 +69,11 @@ 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 -// their own lifecycle and must survive a mere turn settle — a full -// `clearAppShellSessionUiStateForSession` (session deletion) would wipe them too. +// projection. The independently-scoped maps (message load error / retry, +// optimistic permission-mode / model / thinking-level overlays, 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 // `sessionEventHealthBySessionRef`. const TURN_TRANSIENT_MAP_KEYS = [ @@ -181,8 +190,9 @@ export function createAppShellSessionUiStateController( setSessionEventHealthBySession: ((updater) => { sessionEventHealthBySessionRef.current = updater(sessionEventHealthBySessionRef.current); }) satisfies StateUpdater>, - setPendingPermissionModeBySession: createMapSetter('pendingPermissionModeBySession'), - setPendingSessionModelBySession: createMapSetter('pendingSessionModelBySession'), + setOptimisticPermissionModeBySession: createMapSetter('optimisticPermissionModeBySession'), + setOptimisticSessionModelBySession: createMapSetter('optimisticSessionModelBySession'), + setOptimisticSessionThinkingLevelBySession: createMapSetter('optimisticSessionThinkingLevelBySession'), /** * The authority said something about `turnId` — it started, failed to * start, or ended. Drop that arm's `unconfirmed` claim so a session list @@ -243,8 +253,9 @@ export function useAppShellSessionUiState() { setInteractionBySession: controller.setInteractionBySession, setMessageQueueBySession: controller.setMessageQueueBySession, setSessionEventHealthBySession: controller.setSessionEventHealthBySession, - setPendingPermissionModeBySession: controller.setPendingPermissionModeBySession, - setPendingSessionModelBySession: controller.setPendingSessionModelBySession, + setOptimisticPermissionModeBySession: controller.setOptimisticPermissionModeBySession, + setOptimisticSessionModelBySession: controller.setOptimisticSessionModelBySession, + setOptimisticSessionThinkingLevelBySession: controller.setOptimisticSessionThinkingLevelBySession, 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 e4c6724836..9bfd1f5338 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -389,8 +389,9 @@ function AppShellContent({ setInteractionBySession, setMessageQueueBySession, setSessionEventHealthBySession, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, + setOptimisticPermissionModeBySession, + setOptimisticSessionModelBySession, + setOptimisticSessionThinkingLevelBySession, } = useAppShellSessionWorkspace(toastApi); const interactionHydrationEpochRef = useRef(new Map()); const markInteractionChanged = useCallback((sessionId: string) => { @@ -495,8 +496,9 @@ function AppShellContent({ stopPendingBySession, interactionBySession, messageQueueBySession, - pendingPermissionModeBySession, - pendingSessionModelBySession, + optimisticPermissionModeBySession, + optimisticSessionModelBySession, + optimisticSessionThinkingLevelBySession, streamingSessionIds, activeLiveTurnSnapshot, } = useAppShellSessionUiReads(sessionUiController, activeId); @@ -907,6 +909,9 @@ function AppShellContent({ usePersistedComposerDefaults: modelSettingsOwnsComposerHost, defaultThinkingLevel: newTask.selectedHost?.chatDefaults.thinkingLevel, openSettingsSection, + optimisticModel: activeId ? optimisticSessionModelBySession[activeId] : undefined, + hasOptimisticThinkingLevel: activeId ? activeId in optimisticSessionThinkingLevelBySession : false, + optimisticThinkingLevel: activeId ? optimisticSessionThinkingLevelBySession[activeId] : undefined, }); const newChatProviderType = newChatModel ? connections.find((connection) => connection.slug === newChatModel.llmConnectionSlug)?.providerType @@ -917,24 +922,23 @@ 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 - // 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 - // `finally`. + // The de-dup registries (turn-footer actions, 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 others stay ref-only and + // clear in their action's `finally`. Model/thinking-level/permission-mode + // changes use the optimistic-overlay maps in app-shell-session-ui-state.ts + // instead, with no re-entrancy guard. const turnActionRegistry = useKeyedPendingRegistry({ trackState: true, autoClearMs: 5000, }); const pendingTurnActions = turnActionRegistry.keys; const sessionRowActionRegistry = useKeyedPendingRegistry(); - const permissionModeChangeRegistry = useKeyedPendingRegistry(); // One registry per persisted field. The two controls are independent, so a // Plan transition in flight is no reason to hold the orchestration choice. const collaborationModeChangeRegistry = useKeyedPendingRegistry(); const orchestrationModeChangeRegistry = useKeyedPendingRegistry(); - const sessionModelChangeRegistry = useKeyedPendingRegistry(); const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) => `${sessionId}:${turnId}:${actionId}`; function omitSessionKey(current: Record, sessionId: string): Record { @@ -971,9 +975,10 @@ function AppShellContent({ } function clearSessionRendererState(sessionId: string): void { + // clearOwnedSessionState clears every AppShellSessionUiState map, + // including the optimistic overlays — no extra cleanup needed here. clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); - permissionModeChangeRegistry.keysRef.current.delete(sessionId); collaborationModeChangeRegistry.keysRef.current.delete(sessionId); orchestrationModeChangeRegistry.keysRef.current.delete(sessionId); // Queued mode intents die with the Session's renderer lifecycle: an @@ -981,7 +986,6 @@ function AppShellContent({ // Session this cleanup has already let go of. queuedCollaborationModeBySession.current.delete(sessionId); queuedOrchestrationModeBySession.current.delete(sessionId); - sessionModelChangeRegistry.keysRef.current.delete(sessionId); } const sessionRowActionHandlers = useStableActions(createAppShellSessionRowActions, { @@ -1013,16 +1017,14 @@ function AppShellContent({ } = useStableActions(createAppShellSessionSettingsActions, { uiLocale, activeIdRef, - connections, - messages, - pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, - pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, + getOptimisticState: () => sessionUiController.getState(), refreshSessions, saveComposerDefaults, sessionsRef, setNewTaskPermissionMode, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, + setOptimisticPermissionModeBySession, + setOptimisticSessionModelBySession, + setOptimisticSessionThinkingLevelBySession, setSessions, toastApi, }); @@ -2413,8 +2415,6 @@ function AppShellContent({ handleConnectionEvent, openHelp, openSettings, - pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, - pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, pendingTurnActionTimersRef: turnActionRegistry.timersRef, pendingTurnActionsRef: turnActionRegistry.keysRef, projectPickerPendingRef, @@ -3174,7 +3174,6 @@ function AppShellContent({ modelChoices={chatModelChoices} modelSwitchHasHistory={modelSwitchHasHistory} renderProviderMark={(type) => } - modelChangePending={activeId ? pendingSessionModelBySession[activeId] === true : false} onModelChange={(input) => setSessionModel(input)} activeThinkingLevels={activeThinkingLevels} activeThinkingLevel={activeThinkingLevel} @@ -3200,8 +3199,11 @@ function AppShellContent({ sessionHealthNotice?.tone === 'destructive' || taskSubmissionHardBlocked } - permissionMode={activePermissionMode} - permissionModePending={activeId ? pendingPermissionModeBySession[activeId] === true : false} + permissionMode={ + activeId && activeId in optimisticPermissionModeBySession + ? optimisticPermissionModeBySession[activeId] + : activePermissionMode + } // 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 @@ -3209,15 +3211,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 @@ -3277,7 +3277,6 @@ function AppShellContent({ activeProviderType={activeConnection?.providerType} renderProviderMark={(type) => } modelChoices={chatModelChoices} - modelChangePending={activeId ? pendingSessionModelBySession[activeId] === true : false} onModelChange={(input) => setSessionModel(input)} userLabel={userLabel} memoryActive={memoryActive} 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..cc819483aa 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 @@ -18,11 +18,14 @@ */ import type { InteractionQueues } from '@maka/ui'; +import type { PermissionMode } from '@maka/core/permission'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { AppShellSessionUiState, AppShellSessionUiStateController, MessageQueueUiState, } from './app-shell-session-ui-state.js'; +import type { NewChatModel } from './shell-chat-model-selection.js'; import { deriveLiveTurnSnapshot, liveTurnSnapshotsEqual, @@ -37,8 +40,10 @@ 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 selectPendingSessionModel = (state: AppShellSessionUiState) => state.pendingSessionModelBySession; +const selectOptimisticPermissionMode = (state: AppShellSessionUiState) => state.optimisticPermissionModeBySession; +const selectOptimisticSessionModel = (state: AppShellSessionUiState) => state.optimisticSessionModelBySession; +const selectOptimisticSessionThinkingLevel = (state: AppShellSessionUiState) => + state.optimisticSessionThinkingLevelBySession; const selectPulseSet = (state: AppShellSessionUiState) => selectStreamingSessionIds(state.liveTurnBySession); /** @@ -77,8 +82,9 @@ export function useAppShellSessionUiReads( stopPendingBySession: Record; interactionBySession: InteractionQueues; messageQueueBySession: Record; - pendingPermissionModeBySession: Record; - pendingSessionModelBySession: Record; + optimisticPermissionModeBySession: Record; + optimisticSessionModelBySession: Record; + optimisticSessionThinkingLevelBySession: Record; streamingSessionIds: Set; activeLiveTurnSnapshot: LiveTurnSnapshot; } { @@ -88,8 +94,12 @@ export function useAppShellSessionUiReads( stopPendingBySession: useAppShellSessionUiSelector(controller, selectStopPending), interactionBySession: useAppShellSessionUiSelector(controller, selectInteraction), messageQueueBySession: useAppShellSessionUiSelector(controller, selectMessageQueue), - pendingPermissionModeBySession: useAppShellSessionUiSelector(controller, selectPendingPermissionMode), - pendingSessionModelBySession: useAppShellSessionUiSelector(controller, selectPendingSessionModel), + optimisticPermissionModeBySession: useAppShellSessionUiSelector(controller, selectOptimisticPermissionMode), + optimisticSessionModelBySession: useAppShellSessionUiSelector(controller, selectOptimisticSessionModel), + optimisticSessionThinkingLevelBySession: useAppShellSessionUiSelector( + controller, + selectOptimisticSessionThinkingLevel, + ), 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..7bcad6f4e6 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -112,8 +112,9 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { setInteractionBySession: sessionUi.setInteractionBySession, setMessageQueueBySession: sessionUi.setMessageQueueBySession, setSessionEventHealthBySession: sessionUi.setSessionEventHealthBySession, - setPendingPermissionModeBySession: sessionUi.setPendingPermissionModeBySession, - setPendingSessionModelBySession: sessionUi.setPendingSessionModelBySession, + setOptimisticPermissionModeBySession: sessionUi.setOptimisticPermissionModeBySession, + setOptimisticSessionModelBySession: sessionUi.setOptimisticSessionModelBySession, + setOptimisticSessionThinkingLevelBySession: sessionUi.setOptimisticSessionThinkingLevelBySession, confirmLiveTurn: sessionUi.confirmLiveTurn, }; } diff --git a/apps/desktop/src/renderer/use-shell-chat-model.ts b/apps/desktop/src/renderer/use-shell-chat-model.ts index bdda92012c..4dd17e0729 100644 --- a/apps/desktop/src/renderer/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/use-shell-chat-model.ts @@ -70,6 +70,12 @@ export function useShellChatModel(options: { /** Settings → 通用 → 默认思考级别; undefined means "no preference". */ defaultThinkingLevel?: ThinkingLevel; openSettingsSection: (section: SettingsSection) => void; + /** In-flight/just-committed value shown instead of `activeSession`'s field. + * `hasOptimisticThinkingLevel` distinguishes "no override" from an + * override that is itself `undefined` ("use the model's default"). */ + optimisticModel?: NewChatModel; + hasOptimisticThinkingLevel?: boolean; + optimisticThinkingLevel?: ThinkingLevel; }): { chatModelChoices: ChatModelChoice[]; activeConnection: LlmConnection | undefined; @@ -88,8 +94,9 @@ 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, persistedComposerDefaults, openSettingsSection, optimisticModel, hasOptimisticThinkingLevel, optimisticThinkingLevel } = options; const conversationCopy = getDesktopConversationCopy(uiLocale); + const activeConnectionSlug = optimisticModel?.llmConnectionSlug ?? activeSession?.llmConnectionSlug; const [pendingNewChatModelChoice, setPendingNewChatModel] = useNewTaskChoice< NewChatModel | null >( @@ -100,8 +107,8 @@ export function useShellChatModel(options: { : options.usePersistedComposerDefaults ? persistedComposerDefaults?.model ?? null : null; - const activeConnection = activeSession - ? connections.find((connection) => connection.slug === activeSession.llmConnectionSlug) + const activeConnection = activeConnectionSlug + ? connections.find((connection) => connection.slug === activeConnectionSlug) : undefined; const { chatModelChoices } = options; // Home / empty-state composer: which model the next NEW chat starts with. @@ -147,28 +154,31 @@ export function useShellChatModel(options: { options.sessionSendOutcome.reason === 'fake_backend'; const activeConnectionLabel = isRetiredBackend ? conversationCopy.model.fakeBackendLabel - : activeConnection?.name ?? activeSession?.llmConnectionSlug; + : activeConnection?.name ?? activeConnectionSlug; const activeModel = isRetiredBackend ? undefined - : activeSession?.model || activeConnection?.defaultModel; + : optimisticModel?.model ?? (activeSession?.model || activeConnection?.defaultModel); const activeModelLabel = isRetiredBackend ? undefined - : chatModelChoiceLabel(chatModelChoices, activeSession?.llmConnectionSlug, activeModel); + : chatModelChoiceLabel(chatModelChoices, activeConnectionSlug, activeModel); const activeThinkingLevels = useMemo( () => chatModelChoices.find( - (choice) => choice.connectionSlug === activeSession?.llmConnectionSlug && choice.model === activeModel, + (choice) => choice.connectionSlug === activeConnectionSlug && choice.model === activeModel, )?.thinkingLevels ?? [], - [activeSession?.llmConnectionSlug, activeModel, chatModelChoices], + [activeConnectionSlug, activeModel, chatModelChoices], ); // Only surface a stored level when the current model still supports it; // if the model changed (setModel clears it) or the catalog reconfigured so // the level is no longer offered, the chip falls back to 默认 instead of // advertising a level the runtime would silently drop. The runtime's // `buildProviderOptions` is the wire-level guard; this keeps the UI honest. - const activeThinkingLevel = - activeSession?.thinkingLevel && activeThinkingLevels.includes(activeSession.thinkingLevel) - ? activeSession.thinkingLevel - : undefined; + const activeThinkingLevel = hasOptimisticThinkingLevel + ? (optimisticThinkingLevel !== undefined && activeThinkingLevels.includes(optimisticThinkingLevel) + ? optimisticThinkingLevel + : undefined) + : (activeSession?.thinkingLevel && activeThinkingLevels.includes(activeSession.thinkingLevel) + ? activeSession.thinkingLevel + : undefined); const newChatThinkingLevels = useMemo( () => { if (!newChatModel) return []; diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx index e102258695..efd5d7d8c5 100644 --- a/packages/ui/src/chat-model-switcher.tsx +++ b/packages/ui/src/chat-model-switcher.tsx @@ -232,7 +232,7 @@ export function ChatModelSwitcher(props: { isLoading: pending, tooltip: title, className: 'maka-model-switcher-trigger', - 'aria-label': copy.switchAriaLabel, + 'aria-label': displayLabel ? `${copy.switchAriaLabel}: ${displayLabel}` : copy.switchAriaLabel, }} > {announceWarning ? ( 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 c61236553d..e7ae077c61 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -305,7 +305,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[]; @@ -368,7 +367,6 @@ export const Composer = forwardRef< * option (#1611). */ permissionMode?: PermissionMode; - permissionModePending?: boolean; permissionModeDisabledReason?: string; onPermissionModeChange?(mode: PermissionMode): void | Promise; /** @@ -1891,7 +1889,6 @@ export const Composer = forwardRef< }} disabled={ props.disabled - || props.permissionModePending === true || Boolean(props.permissionModeDisabledReason) } disabledReason={props.permissionModeDisabledReason} @@ -1912,7 +1909,6 @@ export const Composer = forwardRef< currentProviderType={props.activeProviderType} choices={props.modelChoices ?? []} hasConversationHistory={props.modelSwitchHasHistory} - pending={props.modelChangePending} disabledReason={modelSwitcherDisabledReason} renderProviderMark={props.renderProviderMark} onChange={props.onModelChange} @@ -1942,9 +1938,8 @@ export const Composer = forwardRef< levels={props.activeThinkingLevels ?? []} current={props.activeThinkingLevel} onChange={props.onThinkingLevelChange} - disabled={Boolean(modelSwitcherDisabledReason) || props.modelChangePending} + disabled={Boolean(modelSwitcherDisabledReason)} disabledReason={thinkingSwitcherDisabledReason} - loading={props.modelChangePending} /> ) : (