diff --git a/apps/desktop/src/main/__tests__/app-shell-deleted-session-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-deleted-session-state.test.ts new file mode 100644 index 0000000000..1d01293f6b --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-deleted-session-state.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { test } from 'node:test'; +import { clearDeletedSessionRendererState } from '../../renderer/deleted-session-renderer-state.js'; + +test('a background deleted event clears only that Session renderer state', () => { + const activeIdRef = { current: 'active' as string | undefined }; + const cleared: string[] = []; + const selections: Array = []; + let messageClears = 0; + + clearDeletedSessionRendererState({ + deletedSessionId: 'background', + activeIdRef, + clearSessionRendererState: (sessionId) => cleared.push(sessionId), + setActiveId: (sessionId) => selections.push(sessionId), + clearActiveMessages: () => { + messageClears += 1; + }, + }); + + assert.deepEqual(cleared, ['background']); + assert.deepEqual(selections, []); + assert.equal(messageClears, 0); + assert.equal(activeIdRef.current, 'active'); +}); + +test('an active deleted event also clears the active selection and messages', () => { + const activeIdRef = { current: 'active' as string | undefined }; + const cleared: string[] = []; + const selections: Array = []; + let messageClears = 0; + + clearDeletedSessionRendererState({ + deletedSessionId: 'active', + activeIdRef, + clearSessionRendererState: (sessionId) => cleared.push(sessionId), + setActiveId: (sessionId) => { + selections.push(sessionId); + activeIdRef.current = sessionId; + }, + clearActiveMessages: () => { + messageClears += 1; + }, + }); + + assert.deepEqual(cleared, ['active']); + assert.deepEqual(selections, [undefined]); + assert.equal(messageClears, 1); + assert.equal(activeIdRef.current, undefined); +}); 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 3b980403cf..0000000000 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ /dev/null @@ -1,354 +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 { 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'; - -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, - 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; - 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 modelCalls: string[] = []; - const permissionCalls: string[] = []; - const thinkingCalls: 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(); - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - maka: { - sessions: { - setPermissionMode: async (sessionId: string, mode: 'ask' | 'bypass') => { - permissionCalls.push(`${sessionId}:${mode}`); - 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; - }, - }, - }, - }, - }); - - 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, - 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) => { - errors.push(title); - errorTargets.push(target); - }, - confirm: options.confirm ?? (async () => true), - }, - }); - - return { - actions, - activeIdRef, - errors, - errorTargets, - modelCalls, - modelResult, - newTaskPermissionModes, - pending, - pendingBySession, - permissionCalls, - sessionsRef, - thinkingCalls, - thinkingResult, - 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; - - const switched = await harness.actions.setPermissionMode('bypass'); - - assert.equal(switched, 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; - }, - }); - - const switched = await harness.actions.setPermissionMode('bypass'); - - assert.equal(switched, false); - assert.equal(confirmations, 1); - assert.deepEqual(harness.permissionCalls, []); - }); - - it('reports a confirmed bypass switch as successful', async () => { - const harness = createHarness(); - - const switched = await harness.actions.setPermissionMode('bypass'); - - assert.equal(switched, true); - assert.deepEqual(harness.permissionCalls, ['session-a:bypass']); - }); - - 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.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', - }]; - - const switched = await harness.actions.setPermissionMode('bypass'); - - assert.equal(switched, 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('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', - }], - }); - - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - 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({ - 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'); - }); - - 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[], - }); - - const modelChange = harness.actions.setSessionModel({ - llmConnectionSlug: 'relay', - model: 'claude-sonnet', - }); - harness.modelResult.resolve({ - ...session('session-a'), - llmConnectionSlug: 'relay', - }); - await modelChange; - - assert.equal( - harness.successes[0]?.description, - 'claude-sonnet (Primary) → claude-sonnet (Relay)', - ); - }); - - 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); - - harness.thinkingResult.resolve(session('session-a')); - await thinkingChange; - assert.equal(harness.pendingBySession['session-a'], undefined); - }); - - 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.equal(harness.errors.length, 1); - 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..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,8 +70,6 @@ function seededState(): AppShellSessionUiState { drop: [boundaryRequest('drop')], keep: [boundaryRequest('keep')], }, - pendingPermissionModeBySession: { drop: true, keep: true }, - pendingSessionModelBySession: { drop: true, keep: true }, }; } @@ -91,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' }, @@ -183,8 +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']); - 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__/new-task-permission-mode-write.test.ts b/apps/desktop/src/main/__tests__/new-task-permission-mode-write.test.ts new file mode 100644 index 0000000000..28d6d17a69 --- /dev/null +++ b/apps/desktop/src/main/__tests__/new-task-permission-mode-write.test.ts @@ -0,0 +1,86 @@ +/* + * 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 { test } from 'node:test'; +import { persistNewTaskPermissionMode } from '../../renderer/new-task-permission-mode-write.js'; + +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 }; +} + +test('does not start a second new-task permission write while one is in flight', async () => { + const inFlight = { current: false }; + const pending = deferred(); + const writes: string[] = []; + const first = persistNewTaskPermissionMode({ + inFlight, + mode: 'bypass', + write: async (mode) => { + writes.push(mode); + return pending.promise; + }, + onError: () => undefined, + }); + + await Promise.resolve(); + const second = await persistNewTaskPermissionMode({ + inFlight, + mode: 'ask', + write: async (mode) => { + writes.push(mode); + }, + onError: () => undefined, + }); + + assert.equal(second, false); + assert.deepEqual(writes, ['bypass']); + pending.resolve(); + assert.equal(await first, true); +}); + +test('releases the new-task permission guard after a failed write', async () => { + const inFlight = { current: false }; + const errors: unknown[] = []; + const failed = await persistNewTaskPermissionMode({ + inFlight, + mode: 'bypass', + write: async () => { + throw new Error('persist failed'); + }, + onError: (error) => errors.push(error), + }); + const retried = await persistNewTaskPermissionMode({ + inFlight, + mode: 'ask', + write: async () => undefined, + onError: (error) => errors.push(error), + }); + + assert.equal(failed, false); + assert.equal(retried, true); + assert.equal(errors.length, 1); + assert.match(String(errors[0]), /persist failed/); +}); diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 431d6bf6b2..35e8d7a95f 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -23,6 +23,7 @@ import { parseHTML } from 'linkedom'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { SessionEvent } from '@maka/core/events'; +import type { PermissionMode } from '@maka/core/permission'; import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; import { createFakeWorkbarServices, @@ -136,6 +137,9 @@ async function renderProbe( onSend?: (send: (text: string) => Promise) => void; onSteer?: (steer: (text: string) => Promise) => void; onStop?: (stop: () => Promise) => void; + onSetPermissionMode?: ( + setPermissionMode: (mode: PermissionMode) => Promise, + ) => void; pendingQuotes?: readonly StagedCompanionQuote[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; } = {}, @@ -161,7 +165,10 @@ async function renderProbe( pendingQuotes: options.pendingQuotes, onQuotesConsumed: options.onQuotesConsumed, }) - : createElement(QuoteCompanionProbe, { sourceSession: options.sourceSession }); + : createElement(QuoteCompanionProbe, { + sourceSession: options.sourceSession, + onSetPermissionMode: options.onSetPermissionMode, + }); await act(async () => { root.render(createElement(WorkbarServicesProvider, { services, children })); @@ -230,6 +237,66 @@ afterEach(async () => { Object.assign(globalThis, originalGlobals); }); +test('projects and commits the latest Side Conversation permission intent', async () => { + const writes: Array<{ + mode: PermissionMode; + result: ReturnType>; + }> = []; + let setPermissionMode!: (mode: PermissionMode) => Promise; + const { container } = await renderProbe( + { + setPermissionMode: async (sessionId, mode) => { + const result = deferred(); + writes.push({ mode, result }); + return result.promise; + }, + }, + { + onSetPermissionMode: (setMode) => { + setPermissionMode = setMode; + }, + }, + ); + const probe = container.firstElementChild; + assert.ok(probe); + + let firstRequest!: Promise; + await act(async () => { + firstRequest = setPermissionMode('bypass'); + await Promise.resolve(); + }); + const afterFirstRequest = probe.getAttribute('data-permission-mode'); + + let secondRequest!: Promise; + await act(async () => { + secondRequest = setPermissionMode('ask'); + await Promise.resolve(); + }); + const afterSecondRequest = probe.getAttribute('data-permission-mode'); + + await act(async () => { + writes[0]?.result.resolve({ + ...session('side-conversation'), + permissionMode: 'bypass', + }); + await Promise.resolve(); + }); + + assert.equal(probe.getAttribute('data-permission-mode'), 'ask'); + assert.equal(afterFirstRequest, 'bypass'); + assert.equal(afterSecondRequest, 'ask'); + assert.deepEqual(writes.map(({ mode }) => mode), ['bypass', 'ask']); + + await act(async () => { + writes[1]?.result.resolve({ + ...session('side-conversation'), + permissionMode: 'ask', + }); + assert.deepEqual(await Promise.all([firstRequest, secondRequest]), [true, true]); + }); + assert.equal(probe.getAttribute('data-permission-mode'), 'ask'); +}); + test('retries a busy Side Conversation at the newest settled boundary and clears its banner', async () => { let listCount = 0; let sessionChange: ((event: SessionChangedEvent) => void) | undefined; @@ -1051,7 +1118,12 @@ test('releases a send waiting for observation when the Side Conversation is disp mountedRoot = undefined; }); -function QuoteCompanionProbe(props: { sourceSession?: SessionSummary }) { +function QuoteCompanionProbe(props: { + sourceSession?: SessionSummary; + onSetPermissionMode?: ( + setPermissionMode: (mode: PermissionMode) => Promise, + ) => void; +}) { const companion = useQuoteCompanion({ panelId: 'retry-panel', pendingQuotes: [], @@ -1059,10 +1131,12 @@ function QuoteCompanionProbe(props: { sourceSession?: SessionSummary }) { locale: 'en', onQuotesConsumed: () => undefined, }); + props.onSetPermissionMode?.(companion.setPermissionMode); return createElement('div', { 'data-error': companion.error ?? '', 'data-companion-id': companion.companionSession?.id ?? '', 'data-preparing': String(companion.preparing), + 'data-permission-mode': companion.permissionMode ?? '', }, companion.error); } diff --git a/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts b/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts index a628321533..056e686c0e 100644 --- a/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts @@ -107,6 +107,68 @@ test('the orchestration default writes its own field alone', async () => { assert.deepEqual(patches, [{ orchestrationMode: 'swarm' }, { orchestrationMode: 'default' }]); }); +test('model and thinking are committed through one configuration patch', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await ipc.invoke('sessions:setModelConfiguration', 'session-1', { + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + thinkingLevel: 'high', + }); + + assert.deepEqual(patches, [{ + modelTarget: { + kind: 'explicit', + connectionSlug: 'openai-main', + model: 'gpt-5', + }, + thinkingLevel: 'high', + }]); +}); + +test('the compound model configuration channel replaces the legacy model-only channel', () => { + const ipc = harness([]); + + assert.equal(ipc.channels.has('sessions:setModelConfiguration'), true); + assert.equal(ipc.channels.has('sessions:setModel'), false); +}); + +test('compound model configuration accepts an explicit default thinking level', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await ipc.invoke('sessions:setModelConfiguration', 'session-1', { + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + thinkingLevel: null, + }); + + assert.equal(patches[0]?.thinkingLevel, null); +}); + +test('compound model configuration rejects a missing or invalid thinking level', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await assert.rejects( + ipc.invoke('sessions:setModelConfiguration', 'session-1', { + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }) as Promise, + /Invalid thinking level/, + ); + await assert.rejects( + ipc.invoke('sessions:setModelConfiguration', 'session-1', { + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + thinkingLevel: 'turbo', + }) as Promise, + /Invalid thinking level/, + ); + assert.deepEqual(patches, []); +}); + test('a Plan Session keeps the orchestration default it was carrying', async () => { const patches: DesktopSessionConfigurationPatch[] = []; const ipc = harness(patches); diff --git a/apps/desktop/src/main/__tests__/session-model-configuration-intent.test.ts b/apps/desktop/src/main/__tests__/session-model-configuration-intent.test.ts new file mode 100644 index 0000000000..c1187964cb --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-model-configuration-intent.test.ts @@ -0,0 +1,84 @@ +/* + * 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 test from 'node:test'; +import { + equalSessionModelConfigurationIntent, + modelConfigurationIntentForModel, + modelConfigurationIntentForThinking, +} from '../../renderer/session-model-configuration-intent.js'; + +const modelA = { llmConnectionSlug: 'openai-main', model: 'model-a' }; +const modelB = { llmConnectionSlug: 'anthropic-main', model: 'model-b' }; + +test('a model selection resets thinking to the new model default', () => { + assert.deepEqual(modelConfigurationIntentForModel(modelB), { + modelTarget: modelB, + thinkingLevel: null, + changedSetting: 'model', + }); +}); + +test('a thinking selection retains the pending cross-connection model target', () => { + const pendingModel = modelConfigurationIntentForModel(modelB); + + assert.deepEqual( + modelConfigurationIntentForThinking(modelA, pendingModel, 'high'), + { + modelTarget: modelB, + thinkingLevel: 'high', + changedSetting: 'thinking', + }, + ); +}); + +test('a later model selection wins over an earlier thinking selection', () => { + const thinking = modelConfigurationIntentForThinking(modelA, undefined, 'high'); + const model = modelConfigurationIntentForModel(modelB); + + assert.deepEqual(thinking, { + modelTarget: modelA, + thinkingLevel: 'high', + changedSetting: 'thinking', + }); + assert.deepEqual(model, { + modelTarget: modelB, + thinkingLevel: null, + changedSetting: 'model', + }); +}); + +test('configuration equality ignores which control produced the same Host payload', () => { + assert.equal( + equalSessionModelConfigurationIntent( + { + modelTarget: modelB, + thinkingLevel: null, + changedSetting: 'model', + }, + { + modelTarget: modelB, + thinkingLevel: null, + changedSetting: 'thinking', + }, + ), + true, + ); +}); diff --git a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts index 3281407947..23e1a77ed9 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts @@ -306,21 +306,35 @@ describe('purgeSessions', () => { // resources, so a rejection is not evidence the task survived. Only the // catalog can settle it. const h = harness(); - const sessions = [summary('committed'), summary('survivor')]; + const sessions = [ + summary('committed'), + summary('committed-v2', { + revisionRootSessionId: 'committed', + revisionParentSessionId: 'committed', + }), + summary('survivor'), + ]; const service = installService(h, { - rejectIds: ['committed', 'survivor'], + rejectIds: ['committed-v2', 'survivor'], surviving: [summary('survivor')], }); - const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, service }); + const actions = createActions({ + harness: h, + sessions, + activeIdRef: { current: 'committed-v2' }, + service, + }); - const outcome = await actions.purgeSessions(['committed', 'survivor']); + const outcome = await actions.purgeSessions(['committed-v2', 'survivor']); assert.equal(h.listCalls, 1); assert.deepEqual(outcome.remaining, ['survivor']); assert.equal(outcome.removed, 1); + assert.deepEqual(h.cleared.sort(), ['committed', 'committed-v2']); + assert.deepEqual(h.selections, [undefined]); assert.ok(outcome.firstFailure); - assert.equal((outcome.firstFailure.error as Error).message, 'busy:committed'); - assert.equal(outcome.firstFailure.sessionId, 'committed'); + assert.equal((outcome.firstFailure.error as Error).message, 'busy:committed-v2'); + assert.equal(outcome.firstFailure.sessionId, 'committed-v2'); }); it('retains the first failing Session even when the rejection value is undefined', async () => { 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..37139fa1e9 100644 --- a/apps/desktop/src/main/__tests__/session-setting-intent.test.ts +++ b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts @@ -24,7 +24,8 @@ import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { useSessionSettingIntent } from '../../renderer/use-session-setting-intent.js'; -type SessionSettingIntentController = ReturnType< +type IntentValue = string | null; +type SessionSettingIntentController = ReturnType< typeof useSessionSettingIntent >; @@ -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,80 @@ test('Runtime leaving Plan after approval supersedes the committed Plan overlay' assert.ok(container); const root = createRoot(container); mountedRoot = root; + return { container, root }; +} + +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, attempted: IntentValue): 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(), + }); +} + +type CompoundValue = { + model: string; + thinkingLevel: string | null; + changedSetting?: 'model' | 'thinking'; +}; + +function CompoundHarness({ + write, + onWriteError = () => {}, + capture, +}: { + write(sessionId: string, value: CompoundValue): Promise; + onWriteError?(sessionId: string, error: unknown, attempted: CompoundValue): void; + capture(controller: SessionSettingIntentController): void; +}) { + const controller = useSessionSettingIntent({ + catalogRevision: 0, + isEqual: (left, right) => + left.model === right.model && left.thinkingLevel === right.thinkingLevel, + write, + refreshCatalog: async () => {}, + onWriteError, + }); + capture(controller); + return null; +} - let controller: SessionSettingIntentController | undefined; - const render = async (catalogRevision: number, catalogValue: boolean) => { +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 +150,572 @@ 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 firstRequest: Promise | undefined; + await act(async () => { + firstRequest = controller?.request('session-1', 'model-b'); + await Promise.resolve(); }); -} + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'model-b'); + let secondResult: boolean | undefined; + let secondRequest: Promise | undefined; + + await act(async () => { + secondRequest = controller?.request('session-1', 'model-c'); + void secondRequest?.then((result) => { + secondResult = result; + }); + await Promise.resolve(); + }); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'model-c'); + assert.deepEqual(writes.map((entry) => entry.value), ['model-b']); + assert.equal(secondResult, undefined); + + await act(async () => { + writes[0]?.result.resolve(true); + await Promise.resolve(); + }); + assert.deepEqual(writes.map((entry) => entry.value), ['model-b', 'model-c']); + assert.equal(secondResult, undefined); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'model-c'); + + await act(async () => { + writes[1]?.result.resolve(true); + assert.deepEqual(await Promise.all([firstRequest, secondRequest]), [true, true]); + }); + assert.equal(secondResult, true); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'model-c'); +}); + +test('same desired requests share the active write result without a duplicate write', async () => { + const { root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const writes: Array>> = []; + await act(async () => { + root.render(createElement(Harness, { + catalogRevision: 0, + catalogValue: 'model-a', + write: async () => { + const result = deferred(); + writes.push(result); + return result.promise; + }, + capture: (next) => { + controller = next; + }, + })); + }); + let firstRequest: Promise | undefined; + await act(async () => { + firstRequest = controller?.request('session-1', 'model-b'); + await Promise.resolve(); + }); + let secondResult: boolean | undefined; + let secondRequest: Promise | undefined; + await act(async () => { + secondRequest = controller?.request('session-1', 'model-b'); + void secondRequest?.then((result) => { + secondResult = result; + }); + await Promise.resolve(); + }); + assert.equal(writes.length, 1); + assert.equal(secondResult, undefined); + + await act(async () => { + writes[0]?.resolve(true); + assert.deepEqual(await Promise.all([firstRequest, secondRequest]), [true, true]); + }); + assert.equal(secondResult, true); + assert.equal(writes.length, 1); +}); + +test('structurally equal desired requests share the active write result', async () => { + const { root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const writes: Array>> = []; + await act(async () => { + root.render(createElement(CompoundHarness, { + write: async () => { + const result = deferred(); + writes.push(result); + return result.promise; + }, + capture: (next) => { + controller = next; + }, + })); + }); + + let firstRequest: Promise | undefined; + let secondRequest: Promise | undefined; + await act(async () => { + firstRequest = controller?.request('session-1', { + model: 'model-b', + thinkingLevel: 'high', + }); + await Promise.resolve(); + secondRequest = controller?.request('session-1', { + model: 'model-b', + thinkingLevel: 'high', + }); + await Promise.resolve(); + }); + + assert.equal(writes.length, 1); + await act(async () => { + writes[0]?.resolve(true); + assert.deepEqual(await Promise.all([firstRequest, secondRequest]), [true, true]); + }); + assert.equal(writes.length, 1); +}); + +test('a thinking selection made during a model write commits the latest compound value', async () => { + const { root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const writes: Array<{ + value: CompoundValue; + result: ReturnType>; + }> = []; + await act(async () => { + root.render(createElement(CompoundHarness, { + write: async (_sessionId, value) => { + const result = deferred(); + writes.push({ value, result }); + return result.promise; + }, + capture: (next) => { + controller = next; + }, + })); + }); + + let modelRequest: Promise | undefined; + let thinkingRequest: Promise | undefined; + await act(async () => { + modelRequest = controller?.request('session-1', { + model: 'model-b', + thinkingLevel: null, + }); + await Promise.resolve(); + thinkingRequest = controller?.request('session-1', { + model: 'model-b', + thinkingLevel: 'high', + }); + await Promise.resolve(); + }); + + assert.deepEqual(writes.map(({ value }) => value), [{ + model: 'model-b', + thinkingLevel: null, + }]); + await act(async () => { + writes[0]?.result.resolve(true); + await Promise.resolve(); + }); + assert.deepEqual(writes.map(({ value }) => value), [ + { model: 'model-b', thinkingLevel: null }, + { model: 'model-b', thinkingLevel: 'high' }, + ]); + await act(async () => { + writes[1]?.result.resolve(true); + assert.deepEqual(await Promise.all([modelRequest, thinkingRequest]), [true, true]); + }); +}); + +test('a structurally equal request reports failure against the latest request context', async () => { + const { root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const write = deferred(); + const errors: CompoundValue[] = []; + await act(async () => { + root.render(createElement(CompoundHarness, { + write: async () => write.promise, + onWriteError: (_sessionId, _error, attempted) => { + errors.push(attempted); + }, + capture: (next) => { + controller = next; + }, + })); + }); + + let firstRequest: Promise | undefined; + let latestRequest: Promise | undefined; + await act(async () => { + firstRequest = controller?.request('session-1', { + model: 'model-b', + thinkingLevel: null, + changedSetting: 'model', + }); + await Promise.resolve(); + latestRequest = controller?.request('session-1', { + model: 'model-b', + thinkingLevel: null, + changedSetting: 'thinking', + }); + await Promise.resolve(); + }); + + await act(async () => { + write.resolve(false); + assert.deepEqual(await Promise.all([firstRequest, latestRequest]), [false, false]); + }); + assert.deepEqual(errors, [{ + model: 'model-b', + thinkingLevel: null, + changedSetting: 'thinking', + }]); +}); + +test('a superseded rejection neither reports nor poisons the latest intent', async () => { + const { container, root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const writes: Array<{ + value: IntentValue; + result: ReturnType>; + }> = []; + const errors: unknown[] = []; + + 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; + }, + onWriteError: (_sessionId, error) => { + errors.push(error); + }, + capture: (next) => { + controller = next; + }, + })); + }); + + let firstRequest: Promise | undefined; + await act(async () => { + firstRequest = controller?.request('session-1', 'model-b'); + await Promise.resolve(); + }); + let latestRequest: Promise | undefined; + await act(async () => { + latestRequest = controller?.request('session-1', 'model-c'); + await Promise.resolve(); + }); + + await act(async () => { + writes[0]?.result.reject(new Error('superseded failure')); + await Promise.resolve(); + }); + assert.deepEqual(writes.map((entry) => entry.value), ['model-b', 'model-c']); + assert.deepEqual(errors, []); + + await act(async () => { + writes[1]?.result.resolve(true); + assert.deepEqual(await Promise.all([firstRequest, latestRequest]), [true, true]); + }); + assert.deepEqual(errors, []); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'model-c'); +}); + +test('a stalled catalog refresh cannot strand the latest write or its callers', async () => { + const { root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const writes: Array<{ + value: IntentValue; + result: ReturnType>; + }> = []; + const refresh = deferred(); + + 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; + }, + refreshCatalog: () => refresh.promise, + capture: (next) => { + controller = next; + }, + })); + }); + + let firstRequest: Promise | undefined; + let latestRequest: Promise | undefined; + await act(async () => { + firstRequest = controller?.request('session-1', 'model-b'); + await Promise.resolve(); + latestRequest = controller?.request('session-1', 'model-c'); + writes[0]?.result.resolve(true); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.deepEqual(writes.map((entry) => entry.value), ['model-b', 'model-c']); + await act(async () => { + writes[1]?.result.resolve(true); + assert.deepEqual(await Promise.all([firstRequest, latestRequest]), [true, true]); + }); +}); + +test('unmount cancels an active request and suppresses its late callbacks', async () => { + const { root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const write = deferred(); + const errors: unknown[] = []; + + await act(async () => { + root.render(createElement(Harness, { + catalogRevision: 0, + catalogValue: 'model-a', + write: async () => write.promise, + onWriteError: (_sessionId, error) => { + errors.push(error); + }, + capture: (next) => { + controller = next; + }, + })); + }); + + let request: Promise | undefined; + await act(async () => { + request = controller?.request('session-1', 'model-b'); + await Promise.resolve(); + }); + await act(() => root.unmount()); + mountedRoot = undefined; + assert.equal(await request, false); + + write.reject(new Error('late failure')); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(errors, []); +}); + +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('reports a terminal false result and rolls back to the last committed value', async () => { + const { container, root } = installDom(); + let controller: SessionSettingIntentController | undefined; + const errors: Array<{ sessionId: string; error: unknown; attempted: IntentValue }> = []; + + await act(async () => { + root.render(createElement(Harness, { + catalogRevision: 0, + catalogValue: 'default', + write: async (_sessionId, value) => value !== 'low', + onWriteError: (sessionId, error, attempted) => { + errors.push({ sessionId, error, attempted }); + }, + capture: (next) => { + controller = next; + }, + })); + }); + + await act(async () => { + assert.equal(await controller?.request('session-1', 'high'), true); + }); + await act(async () => { + assert.equal(await controller?.request('session-1', 'low'), false); + }); + + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'high'); + assert.deepEqual(errors, [{ sessionId: 'session-1', error: undefined, attempted: 'low' }]); +}); + +test('rolls back to a committed null after a later terminal failure', async () => { + const { container, root } = installDom(); + let controller: SessionSettingIntentController | undefined; + + await act(async () => { + root.render(createElement(Harness, { + catalogRevision: 0, + catalogValue: 'high', + write: async (_sessionId, value) => value === null, + capture: (next) => { + controller = next; + }, + })); + }); + + await act(async () => { + assert.equal(await controller?.request('session-1', null), true); + }); + await act(async () => { + assert.equal(await controller?.request('session-1', 'low'), false); + }); + + const output = container.querySelector('output'); + assert.equal(output?.getAttribute('data-owns-overlay'), 'true'); + assert.equal(output?.getAttribute('data-value'), 'null'); +}); + +test('error feedback cannot recreate an overlay cleared by its callback', async () => { + const { container, root } = installDom(); + let controller: SessionSettingIntentController | undefined; + + 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) => { + controller?.clear(sessionId); + }, + capture: (next) => { + controller = next; + }, + })); + }); + + await act(async () => { + await controller?.request('session-1', 'high'); + }); + await act(async () => { + assert.equal(await controller?.request('session-1', 'low'), false); + }); + + const output = container.querySelector('output'); + assert.equal(output?.getAttribute('data-owns-overlay'), 'false'); + assert.equal(output?.getAttribute('data-value'), 'default'); +}); + +test('a throwing error reporter cannot strand the request completion', async () => { + const { root } = installDom(); + let controller: SessionSettingIntentController | undefined; + + await act(async () => { + root.render(createElement(Harness, { + catalogRevision: 0, + catalogValue: 'default', + write: async () => { + throw new Error('write failure'); + }, + onWriteError: () => { + throw new Error('reporter failure'); + }, + capture: (next) => { + controller = next; + }, + })); + }); + + let result: boolean | 'timeout' | undefined; + await act(async () => { + result = await Promise.race([ + controller?.request('session-1', 'low'), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 20)), + ]); + }); + assert.equal(result, false); +}); + +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/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index c2ccd258d8..6caa266257 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -21,7 +21,7 @@ import { randomUUID } from 'node:crypto'; import { isCollaborationMode } from '@maka/core/collaboration'; import { isOrchestrationMode } from '@maka/core/orchestration'; import { isPermissionMode } from '@maka/core/permission'; -import { isThinkingLevel } from '@maka/core/model-thinking'; +import { isThinkingLevel, type ThinkingLevel } from '@maka/core/model-thinking'; import { type CreateSessionRequestInput, type SessionListFilter } from '@maka/core/runtime-inputs'; import { type SessionChangedEvent, type SessionChangedReason, type SessionCatalogSummary } from '@maka/core/session'; import { projectSessionCatalogSummary } from '@maka/runtime-host/client'; @@ -198,10 +198,14 @@ export function registerRuntimeHostSessionCatalogIpc( return updateConfiguration(deps, sessionId, { orchestrationMode: mode }, 'mode-change'); }, ); - ipcMain.handle('sessions:setModel', async (_event, sessionId: string, input: unknown) => { - const modelTarget = normalizeExplicitModel(input); - return updateConfiguration(deps, sessionId, { modelTarget, thinkingLevel: null }, 'updated'); - }); + ipcMain.handle( + 'sessions:setModelConfiguration', + async (_event, sessionId: string, input: unknown) => { + const modelTarget = normalizeExplicitModel(input); + const thinkingLevel = normalizeRequiredThinkingLevel(input); + return updateConfiguration(deps, sessionId, { modelTarget, thinkingLevel }, 'updated'); + }, + ); ipcMain.handle('sessions:setThinkingLevel', async (_event, sessionId: string, level: unknown) => { if (level !== undefined && level !== null && !isThinkingLevel(level)) { throw new Error(`Invalid thinking level: ${String(level)}`); @@ -312,6 +316,14 @@ function normalizeExplicitModel(input: unknown): Extract | null)?.thinkingLevel; + if (level !== null && !isThinkingLevel(level)) { + throw new Error(`Invalid thinking level: ${String(level)}`); + } + return level; +} + function normalizeOptionalString(value: unknown, label: string): string | undefined { if (value === undefined) return undefined; if (typeof value !== 'string' || value.trim().length === 0) { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f2aa6d8817..2613ae3684 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -957,7 +957,11 @@ export interface MakaBridge { executionId: string; }>; abandonPlanExecution(sessionId: string, executionId: string): Promise; - setModel(sessionId: string, input: { llmConnectionSlug: string; model: string }): Promise; + setModelConfiguration(sessionId: string, input: { + llmConnectionSlug: string; + model: string; + thinkingLevel: ThinkingLevel | null; + }): Promise; setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise; /** * `requireArchived` holds the caller's premise through the deletion: a task diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 1671c2da6f..28ac53cc4b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1953,8 +1953,12 @@ const makaBridge = { abandonPlanExecution(sessionId: string, executionId: string): Promise { return invokeProjectedSessionRuntimeHost('plan-mode:abandonExecution', sessionId, executionId); }, - setModel(sessionId: string, input: { llmConnectionSlug: string; model: string }): Promise { - return invokeSessionSummary('sessions:setModel', sessionId, input); + setModelConfiguration(sessionId: string, input: { + llmConnectionSlug: string; + model: string; + thinkingLevel: ThinkingLevel | null; + }): Promise { + return invokeSessionSummary('sessions:setModelConfiguration', sessionId, input); }, setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise { return invokeSessionSummary('sessions:setThinkingLevel', sessionId, level ?? undefined); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 20c83217a8..c98cdf9818 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -57,6 +57,7 @@ import { DesktopTranscriptRangeStore, type DesktopTranscriptRangeController, } from './desktop-transcript-range-store.js'; +import { clearDeletedSessionRendererState } from './deleted-session-renderer-state.js'; type RefBox = { current: T }; @@ -159,8 +160,6 @@ export function useAppShellBootstrapSubscriptions(options: { handleConnectionEvent: (event: ConnectionEvent) => void; openHelp: () => void; openSettings: () => void; - pendingPermissionModeChangesRef: RefBox>; - pendingSessionModelChangesRef: RefBox>; pendingTurnActionTimersRef: RefBox>>; pendingTurnActionsRef: RefBox>; projectPickerPendingRef: RefBox; @@ -248,11 +247,14 @@ export function useAppShellBootstrapSubscriptions(options: { const copy = getDesktopConversationCopy(options.uiLocale).actions; options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(event.modelId)); } - if (event.reason === 'deleted' && event.sessionId && event.sessionId === options.activeIdRef.current) { - const deletedSessionId = event.sessionId; - options.setActiveId(undefined); - options.setMessages([]); - options.clearSessionRendererState(deletedSessionId); + if (event.reason === 'deleted' && event.sessionId) { + clearDeletedSessionRendererState({ + deletedSessionId: event.sessionId, + activeIdRef: options.activeIdRef, + clearSessionRendererState: options.clearSessionRendererState, + setActiveId: options.setActiveId, + clearActiveMessages: () => options.setMessages([]), + }); } }, ); @@ -293,8 +295,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 deleted file mode 100644 index 3140d55a26..0000000000 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ /dev/null @@ -1,254 +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 { 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'; -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; - setSessionModel(input: { llmConnectionSlug: string; model: string }): Promise; - setSessionThinkingLevel(level: ThinkingLevel | undefined): Promise; -} - -export function createAppShellSessionSettingsActions(deps: { - uiLocale: UiLocale; - activeIdRef: RefBox; - connections: readonly LlmConnection[]; - messages: readonly StoredMessage[]; - 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, - connections, - messages, - pendingPermissionModeChangesRef, - pendingSessionModelChangesRef, - refreshSessions, - saveComposerDefaults, - sessionsRef, - setNewTaskPermissionMode, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, - 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; - 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)); - } - } - - 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) => ({ - ...current, - [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, - ); - } - 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); - if (activeIdRef.current === sessionId) { - toastApi.success(copy.thinkingUpdatedTitle, level ? copy.thinkingLabels[level] : copy.thinkingDefault); - } - 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..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,8 +33,6 @@ export interface AppShellSessionUiState { shellRunUpdatesBySession: ShellRunUpdatesBySession; interactionBySession: InteractionQueues; messageQueueBySession: Record; - pendingPermissionModeBySession: Record; - pendingSessionModelBySession: Record; } // The pending plate keeps the Host revision beside its entries so edits can @@ -54,8 +52,6 @@ const SESSION_UI_MAP_KEYS = [ 'shellRunUpdatesBySession', 'interactionBySession', 'messageQueueBySession', - 'pendingPermissionModeBySession', - 'pendingSessionModelBySession', ] as const satisfies readonly AppShellSessionUiStateMapKey[]; type MissingSessionUiMapKey = Exclude; @@ -65,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 / model 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 @@ -184,8 +180,6 @@ export function createAppShellSessionUiStateController( setSessionEventHealthBySession: ((updater) => { 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 @@ -246,8 +240,6 @@ export function useAppShellSessionUiState() { setInteractionBySession: controller.setInteractionBySession, 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 d398cbff8e..060d8f7c1b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -36,7 +36,9 @@ 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 { 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'; @@ -137,8 +139,16 @@ 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 { + equalSessionModelConfigurationIntent, + modelConfigurationIntentForModel, + modelConfigurationIntentForThinking, + type SessionModelConfigurationIntent, + type SessionModelTarget, +} from './session-model-configuration-intent'; import { deriveStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; +import { persistNewTaskPermissionMode } from './new-task-permission-mode-write'; import { useAppShellTurnPresentation } from './app-shell-turn-view-model'; import { readScrollMotionBehavior } from './scroll-motion-policy'; import { readNavigationState, selectNavigation } from './nav-selection'; @@ -184,7 +194,6 @@ import { type TurnRevisionDraft, } from './app-shell-revision-actions'; import { createAppShellSessionStartActions } from './app-shell-session-start-actions'; -import { createAppShellSessionSettingsActions } from './app-shell-session-settings-actions'; import { createAppShellStopAction } from './app-shell-stop-action'; import { useStableActions } from './use-stable-actions'; import { @@ -369,8 +378,6 @@ function AppShellContent({ setInteractionBySession, setMessageQueueBySession, setSessionEventHealthBySession, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, } = useAppShellSessionWorkspace(toastApi); const interactionHydrationEpochRef = useRef(new Map()); const markInteractionChanged = useCallback((sessionId: string) => { @@ -506,8 +513,6 @@ function AppShellContent({ stopPendingBySession, interactionBySession, messageQueueBySession, - pendingPermissionModeBySession, - pendingSessionModelBySession, streamingSessionIds, activeLiveTurnSnapshot, } = useAppShellSessionUiReads(sessionUiController, activeId); @@ -615,6 +620,7 @@ function AppShellContent({ setUiLocalePreference, }); const shellCopy = getShellCopy(uiLocale).app; + const sessionSettingsCopy = getShellCopy(uiLocale).sessionSettingsActions; const previousInterruptionCopy = getShellRemainingCopy(uiLocale).previousMainProcessInterruption; const projectActionsCopy = getShellCopy(uiLocale).projectActions; @@ -802,6 +808,156 @@ function AppShellContent({ const activeSession = sessions.find((session) => session.id === activeId); const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; const activeDesktopSession = activeSession; + const modelConfigurationIntent = useSessionSettingIntent({ + catalogRevision, + isEqual: equalSessionModelConfigurationIntent, + write: async (sessionId, configuration) => { + const summary = await window.maka.sessions.setModelConfiguration(sessionId, { + ...configuration.modelTarget, + thinkingLevel: configuration.thinkingLevel, + }); + const committed = + summary.llmConnectionSlug === configuration.modelTarget.llmConnectionSlug && + summary.model === configuration.modelTarget.model && + (summary.thinkingLevel ?? null) === configuration.thinkingLevel; + if (committed && configuration.changedSetting === 'model') { + saveComposerDefaults({ model: configuration.modelTarget }); + } + return committed; + }, + refreshCatalog: refreshSessions, + onWriteError: (sessionId, error, attempted) => { + if (activeIdRef.current !== sessionId) return; + const copy = attempted.changedSetting === 'model' + ? { + title: sessionSettingsCopy.modelFailedTitle, + fallback: sessionSettingsCopy.modelFallback, + } + : { + title: sessionSettingsCopy.thinkingFailedTitle, + fallback: sessionSettingsCopy.thinkingFallback, + }; + showSessionError( + sessionId, + copy.title, + localizedShellErrorMessage(error, copy.fallback, uiLocale), + ); + }, + }); + 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, + ), + ); + }, + }); + const newTaskPermissionModeWriteInFlightRef = useRef(false); + async function setPermissionMode(mode: PermissionMode): Promise { + if (mode !== 'ask' && mode !== 'bypass') return false; + const owner = captureComposerImportOwner(); + const sessionId = owner.sessionId; + const ownsPermissionOverlay = Boolean( + sessionId && Object.hasOwn(permissionModeIntent.overlayBySession, sessionId), + ); + const currentMode = sessionId + ? ownsPermissionOverlay + ? permissionModeIntent.overlayBySession[sessionId] + : sessionsRef.current.find((session) => session.id === sessionId)?.permissionMode + : newTaskPermissionMode; + if (currentMode === mode) { + if (sessionId && ownsPermissionOverlay) { + return permissionModeIntent.request(sessionId, 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); + return persistNewTaskPermissionMode({ + inFlight: newTaskPermissionModeWriteInFlightRef, + mode, + write: setNewTaskPermissionMode, + onError: (error) => { + toastApi.error( + sessionSettingsCopy.permissionFailedTitle, + localizedShellErrorMessage( + error, + sessionSettingsCopy.permissionFallback, + uiLocale, + ), + ); + }, + }); + } + function setSessionModel( + sessionId: string, + modelTarget: SessionModelTarget, + ): Promise { + return modelConfigurationIntent.request( + sessionId, + modelConfigurationIntentForModel(modelTarget), + ); + } + function setSessionThinkingLevel( + sessionId: string, + thinkingLevel: ThinkingLevel | null, + ): Promise { + const pending = modelConfigurationIntent.overlayBySession[sessionId]; + const session = sessionsRef.current.find((candidate) => candidate.id === sessionId); + const currentModelTarget = session + ? { + llmConnectionSlug: session.llmConnectionSlug, + model: session.model, + } + : undefined; + const next = modelConfigurationIntentForThinking( + currentModelTarget, + pending, + thinkingLevel, + ); + return next + ? modelConfigurationIntent.request(sessionId, next) + : Promise.resolve(false); + } + const modelConfigurationOverlay = activeSession + ? modelConfigurationIntent.overlayBySession[activeSession.id] + : undefined; + const activeSessionForModelControls = activeSession + ? { + ...activeSession, + ...(modelConfigurationOverlay + ? { + llmConnectionSlug: modelConfigurationOverlay.modelTarget.llmConnectionSlug, + model: modelConfigurationOverlay.modelTarget.model, + thinkingLevel: modelConfigurationOverlay.thinkingLevel ?? 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 // semantic snapshot rather than the projection (#1985). @@ -871,7 +1027,8 @@ function AppShellContent({ activationCandidate: modelSettingsOwnsComposerHost ? onboardingActivationCandidate : undefined, - activeSession, + activeSession: activeSessionForModelControls, + sessionHealthSession: activeSession, persistedComposerDefaults, usePersistedComposerDefaults: modelSettingsOwnsComposerHost, defaultThinkingLevel: newTask.selectedHost?.chatDefaults.thinkingLevel, @@ -886,16 +1043,14 @@ 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. - // These de-dup registries (turn-footer actions and per-session permission-mode - // / model changes) share the same keyed-Set shape; see - // useKeyedPendingRegistry. Session-row mutations live in Session Navigation. + // The turn-footer de-dup registry mirrors into React state to drive its + // disabled mask and arms a 5s auto-clear fallback. Session-row mutations now + // live in Session Navigation; settings use their intent controllers. const turnActionRegistry = useKeyedPendingRegistry({ trackState: true, autoClearMs: 5000, }); const pendingTurnActions = turnActionRegistry.keys; - const permissionModeChangeRegistry = useKeyedPendingRegistry(); - const sessionModelChangeRegistry = useKeyedPendingRegistry(); const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) => `${sessionId}:${turnId}:${actionId}`; function omitSessionKey(current: Record, sessionId: string): Record { @@ -929,32 +1084,12 @@ function AppShellContent({ function clearSessionRendererState(sessionId: string): void { clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); - permissionModeChangeRegistry.keysRef.current.delete(sessionId); planModeIntent.clear(sessionId); orchestrationModeIntent.clear(sessionId); - sessionModelChangeRegistry.keysRef.current.delete(sessionId); + modelConfigurationIntent.clear(sessionId); + permissionModeIntent.clear(sessionId); } - const { - setPermissionMode, - setSessionModel, - setSessionThinkingLevel, - } = useStableActions(createAppShellSessionSettingsActions, { - uiLocale, - activeIdRef, - connections, - messages, - pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, - pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, - refreshSessions, - saveComposerDefaults, - sessionsRef, - setNewTaskPermissionMode, - setPendingPermissionModeBySession, - setPendingSessionModelBySession, - 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 @@ -964,6 +1099,7 @@ function AppShellContent({ write: commitPlanMode, refreshCatalog: refreshSessions, onWriteError: (sessionId, error) => { + if (error === undefined) return; if (activeIdRef.current !== sessionId) return; showSessionError( sessionId, @@ -988,7 +1124,6 @@ function AppShellContent({ ); }, }); - /** * Enter or leave Plan for one Session — the only path that writes * `collaborationMode`, and it writes nothing else. @@ -1252,7 +1387,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, @@ -2255,8 +2393,6 @@ function AppShellContent({ handleConnectionEvent, openHelp, openSettings, - pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, - pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, pendingTurnActionTimersRef: turnActionRegistry.timersRef, pendingTurnActionsRef: turnActionRegistry.keysRef, projectPickerPendingRef, @@ -2920,17 +3056,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) void setSessionModel(activeId, input); + }} activeThinkingLevels={activeThinkingLevels} activeThinkingLevel={activeThinkingLevel} - onThinkingLevelChange={(level) => setSessionThinkingLevel(level)} + onThinkingLevelChange={(level) => { + if (activeId) void setSessionThinkingLevel(activeId, level ?? null); + }} newChatModel={newChatModel} newChatProviderType={newChatProviderType} onPickNewChatModel={(input) => { @@ -2953,7 +3095,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 @@ -2961,15 +3102,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 @@ -3029,8 +3168,9 @@ function AppShellContent({ activeProviderType={activeConnection?.providerType} renderProviderMark={(type) => } modelChoices={chatModelChoices} - modelChangePending={activeId ? pendingSessionModelBySession[activeId] === true : false} - onModelChange={(input) => setSessionModel(input)} + onModelChange={(input) => { + if (activeId) void setSessionModel(activeId, input); + }} userLabel={userLabel} memoryActive={memoryActive} onOpenMemorySettings={() => openSettingsSection('memory')} diff --git a/apps/desktop/src/renderer/deleted-session-renderer-state.ts b/apps/desktop/src/renderer/deleted-session-renderer-state.ts new file mode 100644 index 0000000000..db67c1bb65 --- /dev/null +++ b/apps/desktop/src/renderer/deleted-session-renderer-state.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +type RefBox = { current: T }; + +/** + * Applies an authoritative Session deletion to renderer-owned state. + * + * The deletion fact is supplied by a targeted `deleted` event, not inferred + * from a catalog snapshot. Every deleted Session loses its per-Session state, + * including background setting intents. Selection and transcript state are + * narrower: they change only when the deleted Session is the active one. + * Keeping those responsibilities separate prevents a background deletion + * from blanking the conversation the user is currently reading. + */ +export function clearDeletedSessionRendererState(input: { + deletedSessionId: string; + activeIdRef: RefBox; + clearSessionRendererState(sessionId: string): void; + setActiveId(sessionId: string | undefined): void; + clearActiveMessages(): void; +}): void { + input.clearSessionRendererState(input.deletedSessionId); + if (input.deletedSessionId !== input.activeIdRef.current) return; + input.setActiveId(undefined); + input.clearActiveMessages(); +} diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts index aca2d257f1..a78286e4b6 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts @@ -102,6 +102,14 @@ export function createSessionNavigationRowActions(deps: { } = deps; const copy = getShellCopy(uiLocale).sessionRowActions; + function clearSessionFamilyRendererState(familyIds: readonly string[]): void { + if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { + setActiveId(undefined); + clearActiveMessages(); + } + for (const id of familyIds) clearSessionRendererState(id); + } + async function runSessionRowAction( sessionId: string, actionId: 'flag' | 'archive' | 'rename' | 'delete', @@ -202,11 +210,7 @@ export function createSessionNavigationRowActions(deps: { requireArchived: options.requireArchived, }); if (disposition === 'restored') return disposition; - if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { - setActiveId(undefined); - clearActiveMessages(); - } - for (const id of familyIds) clearSessionRendererState(id); + clearSessionFamilyRendererState(familyIds); return disposition; } @@ -235,6 +239,12 @@ export function createSessionNavigationRowActions(deps: { * the one thing single-row delete cannot phrase. */ async function purgeSessions(sessionIds: readonly string[]): Promise { + const familyIdsBySession = new Map( + sessionIds.map((sessionId) => [ + sessionId, + revisionFamilySessionIds(sessionsRef.current, sessionId), + ]), + ); const unsettled: string[] = []; const restored: string[] = []; let firstFailure: SessionPurgeOutcome['firstFailure']; @@ -289,6 +299,10 @@ export function createSessionNavigationRowActions(deps: { } const present = new Set(listed.map((session) => session.id)); const remaining = unsettled.filter((sessionId) => present.has(sessionId)); + for (const sessionId of unsettled) { + if (present.has(sessionId)) continue; + clearSessionFamilyRendererState(familyIdsBySession.get(sessionId) ?? [sessionId]); + } return { removed: removed + (unsettled.length - remaining.length), remaining, 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..104841cd9a 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,7 +288,6 @@ 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 } diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index adb5c99a67..64abc7e009 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -60,6 +60,7 @@ import { type StagedCompanionQuote, } from './quote-companion-panel-state.js'; import type { CompanionForkVisibilityEvent } from './quote-companion-visibility.js'; +import { useSessionSettingIntent } from '../../../../use-session-setting-intent.js'; type PendingAdmission = { messageId: string; @@ -123,7 +124,6 @@ export interface UseQuoteCompanionResult { processing: boolean; preparing: boolean; permissionMode: PermissionMode | undefined; - permissionModePending: boolean; regeneratePendingTurnId: string | null; /** A localized, retryable error (fork setup, run error, or a rejected send). */ error: string | null; @@ -214,7 +214,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ); const turnInFlight = streaming; const [preparing, setPreparing] = useState(Boolean(sourceSession)); - const [permissionModePending, setPermissionModePending] = useState(false); const [regeneratePendingTurnId, setRegeneratePendingTurnId] = useState( null, ); @@ -230,6 +229,23 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // double-invoke; a hand-rolled disposed flag would stay tripped after replay). const mountedRef = useMountedRef(); const dismissalGuardRef = useRef(createCompanionDismissalGuard()); + const [permissionCatalogRevision, setPermissionCatalogRevision] = useState(0); + const permissionModeIntent = useSessionSettingIntent({ + catalogRevision: permissionCatalogRevision, + write: async (sessionId, mode) => { + const next = await sideChat.setPermissionMode(sessionId, mode); + if (!mountedRef.current) return false; + companionRef.current = next; + setCompanion(next); + return next.permissionMode === mode; + }, + refreshCatalog: async () => { + if (mountedRef.current) setPermissionCatalogRevision((revision) => revision + 1); + }, + onWriteError: () => { + if (mountedRef.current) setError(copyRef.current.errors.respondFailed); + }, + }); const setPendingAdmission = useCallback((admission: PendingAdmission | null) => { pendingAdmissionRef.current = admission; @@ -812,25 +828,13 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ]); const setPermissionMode = useCallback( - async (mode: PermissionMode): Promise => { + (mode: PermissionMode): Promise => { const id = companionIdRef.current; - if (!id || turnInFlight || permissionModePending) return false; - setPermissionModePending(true); - try { - const next = await sideChat.setPermissionMode(id, mode); - if (!mountedRef.current) return false; - companionRef.current = next; - setCompanion(next); - setError(null); - return true; - } catch { - if (mountedRef.current) setError(copyRef.current.errors.respondFailed); - return false; - } finally { - if (mountedRef.current) setPermissionModePending(false); - } + if (!id || turnInFlight) return Promise.resolve(false); + setError(null); + return permissionModeIntent.request(id, mode); }, - [mountedRef, permissionModePending, sideChat, turnInFlight], + [permissionModeIntent.request, turnInFlight], ); const regenerate = useCallback( @@ -902,8 +906,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan : sourceSession ? { llmConnectionSlug: sourceSession.llmConnectionSlug, model: sourceSession.model } : undefined; - const permissionMode = (companion?.permissionMode ?? - sourceSession?.permissionMode) as PermissionMode | undefined; + const permissionMode = (companionIdRef.current + ? permissionModeIntent.overlayBySession[companionIdRef.current] + ?? companion?.permissionMode + ?? sourceSession?.permissionMode + : companion?.permissionMode ?? sourceSession?.permissionMode) as PermissionMode | undefined; const activeInteraction = companionIdRef.current ? activeInteractionFor(interactions, companionIdRef.current) : undefined; @@ -921,7 +928,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan processing, preparing, permissionMode, - permissionModePending, regeneratePendingTurnId, error, activeModel, diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index a858a39464..a5e9c8df85 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -23,11 +23,10 @@ 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'; -import { type ThinkingLevel } from '@maka/core/model-thinking'; import { type GoalStatus } from '@maka/core/goal'; export const STATIC_COMMAND_IDS = [ @@ -324,22 +323,14 @@ 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; - modelSwitchedTitle: string; - modelSwitchedDescription(from: string, to: string): string; modelFailedTitle: string; modelFallback: string; - thinkingUpdatedTitle: string; - thinkingDefault: string; - thinkingLabels: Record; thinkingFailedTitle: string; thinkingFallback: string; }; @@ -489,7 +480,6 @@ type ShellCopy = { boundaryUnreadableDetail: string; boundaryUnreadableRetry: string; boundaryUnreadableRetrying: string; - permissionModeChanging: string; permissionModeStreaming: string; permissionModeRunning: string; permissionModeWaiting: string; @@ -980,38 +970,15 @@ 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: '权限模式暂时无法切换,请稍后重试。', - modelSwitchedTitle: '已切换当前任务模型', - modelSwitchedDescription: (from, to) => `${from} → ${to}`, modelFailedTitle: '切换模型失败', modelFallback: '模型暂时无法切换,请稍后重试。', - thinkingUpdatedTitle: '已更新思考级别', - thinkingDefault: '默认', - thinkingLabels: { - off: '关', - minimal: '最少', - low: '低', - medium: '中', - high: '高', - xhigh: '超高', - max: '最高', - }, thinkingFailedTitle: '切换思考级别失败', thinkingFallback: '思考级别暂时无法切换,请稍后重试。', }, @@ -1237,7 +1204,6 @@ const SHELL_COPY_BY_LOCALE = { boundaryUnreadableDetail: '在读到之前,这里暂时不能输入。可以重试,或先切换到别的任务。', boundaryUnreadableRetry: '重试', boundaryUnreadableRetrying: '重试中…', - permissionModeChanging: '权限模式正在切换,完成后再继续操作。', permissionModeStreaming: '当前任务正在流式输出,等结束后再切换权限模式。', permissionModeRunning: '当前任务正在运行,等结束后再切换权限模式。', permissionModeWaiting: '当前有工具调用正在等待确认,处理后再切换权限模式。', @@ -1506,38 +1472,15 @@ 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.', - 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.', }, @@ -1804,7 +1747,6 @@ const SHELL_COPY_BY_LOCALE = { 'Until they can be read, you cannot type here. Try again, or switch to another task.', boundaryUnreadableRetry: 'Try again', boundaryUnreadableRetrying: 'Trying again…', - permissionModeChanging: 'The permission mode is changing. Wait for it to finish before continuing.', permissionModeStreaming: 'This task is streaming. Wait for it to finish before changing the permission mode.', permissionModeRunning: 'This task is running. Wait for it to finish before changing the permission mode.', diff --git a/apps/desktop/src/renderer/new-task-permission-mode-write.ts b/apps/desktop/src/renderer/new-task-permission-mode-write.ts new file mode 100644 index 0000000000..1775de94a5 --- /dev/null +++ b/apps/desktop/src/renderer/new-task-permission-mode-write.ts @@ -0,0 +1,49 @@ +/* + * 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'; + +/** + * Serializes persistence of the new-task permission default. + * + * Existing Sessions use `useSessionSettingIntent`, but a new-task choice has + * no Session id or Host catalog revision. It still needs a synchronous guard: + * without one, rapid choices can start concurrent settings writes whose + * completion order differs from the user's click order. The caller owns + * confirmation and localized feedback; this boundary owns only admission, + * completion, and guaranteed release after success or failure. + */ +export async function persistNewTaskPermissionMode(input: { + inFlight: { current: boolean }; + mode: ChatDefaultPermissionMode; + write(mode: ChatDefaultPermissionMode): void | Promise; + onError(error: unknown): void; +}): Promise { + if (input.inFlight.current) return false; + input.inFlight.current = true; + try { + await input.write(input.mode); + return true; + } catch (error) { + input.onError(error); + return false; + } finally { + input.inFlight.current = false; + } +} diff --git a/apps/desktop/src/renderer/session-model-configuration-intent.ts b/apps/desktop/src/renderer/session-model-configuration-intent.ts new file mode 100644 index 0000000000..80abd882ad --- /dev/null +++ b/apps/desktop/src/renderer/session-model-configuration-intent.ts @@ -0,0 +1,64 @@ +/* + * 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'; + +export type SessionModelTarget = { + llmConnectionSlug: string; + model: string; +}; + +export type SessionModelConfigurationIntent = { + modelTarget: SessionModelTarget; + thinkingLevel: ThinkingLevel | null; + changedSetting: 'model' | 'thinking'; +}; + +export function equalSessionModelConfigurationIntent( + left: SessionModelConfigurationIntent, + right: SessionModelConfigurationIntent, +): boolean { + return left.modelTarget.llmConnectionSlug === right.modelTarget.llmConnectionSlug && + left.modelTarget.model === right.modelTarget.model && + left.thinkingLevel === right.thinkingLevel; +} + +export function modelConfigurationIntentForModel( + modelTarget: SessionModelTarget, +): SessionModelConfigurationIntent { + return { + modelTarget, + thinkingLevel: null, + changedSetting: 'model', + }; +} + +export function modelConfigurationIntentForThinking( + currentModelTarget: SessionModelTarget | undefined, + pending: SessionModelConfigurationIntent | undefined, + thinkingLevel: ThinkingLevel | null, +): SessionModelConfigurationIntent | undefined { + const modelTarget = pending?.modelTarget ?? currentModelTarget; + if (!modelTarget) return undefined; + return { + modelTarget, + thinkingLevel, + changedSetting: 'thinking', + }; +} 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..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,8 +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 selectPendingSessionModel = (state: AppShellSessionUiState) => state.pendingSessionModelBySession; const selectPulseSet = (state: AppShellSessionUiState) => selectStreamingSessionIds(state.liveTurnBySession); /** @@ -77,8 +75,6 @@ export function useAppShellSessionUiReads( stopPendingBySession: Record; interactionBySession: InteractionQueues; messageQueueBySession: Record; - pendingPermissionModeBySession: Record; - pendingSessionModelBySession: Record; streamingSessionIds: Set; activeLiveTurnSnapshot: LiveTurnSnapshot; } { @@ -88,8 +84,6 @@ export function useAppShellSessionUiReads( stopPendingBySession: useAppShellSessionUiSelector(controller, selectStopPending), 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..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,8 +112,6 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { setInteractionBySession: sessionUi.setInteractionBySession, setMessageQueueBySession: sessionUi.setMessageQueueBySession, setSessionEventHealthBySession: sessionUi.setSessionEventHealthBySession, - setPendingPermissionModeBySession: sessionUi.setPendingPermissionModeBySession, - setPendingSessionModelBySession: sessionUi.setPendingSessionModelBySession, confirmLiveTurn: sessionUi.confirmLiveTurn, }; } diff --git a/apps/desktop/src/renderer/use-session-setting-intent.ts b/apps/desktop/src/renderer/use-session-setting-intent.ts index bcde3ded5b..9218bb5440 100644 --- a/apps/desktop/src/renderer/use-session-setting-intent.ts +++ b/apps/desktop/src/renderer/use-session-setting-intent.ts @@ -24,13 +24,16 @@ interface SettingIntent { committed?: Value; committedAtCatalogRevision?: number; inFlight: boolean; + completion: Promise; + resolveCompletion(succeeded: boolean): void; } interface SessionSettingIntentOptions { catalogRevision: number; + isEqual?(left: Value, right: Value): boolean; write(sessionId: string, value: Value): Promise; refreshCatalog(): Promise; - onWriteError(sessionId: string, error: unknown): void; + onWriteError(sessionId: string, error: unknown, attempted: Value): void; } interface SessionSettingIntentController { @@ -39,6 +42,26 @@ interface SessionSettingIntentController { clear(sessionId: string): void; } +function createIntent( + desired: Value, + previous?: SettingIntent, +): SettingIntent { + let resolveCompletion!: (succeeded: boolean) => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + return { + desired, + ...(previous?.committed !== undefined ? { committed: previous.committed } : {}), + ...(previous?.committedAtCatalogRevision !== undefined + ? { committedAtCatalogRevision: previous.committedAtCatalogRevision } + : {}), + inFlight: true, + completion, + resolveCompletion, + }; +} + /** * Owns the gap between a renderer setting intent, its Host commit, and the * next successful catalog observation. Only the latest desired value is @@ -52,12 +75,19 @@ export function useSessionSettingIntent( const optionsRef = useRef(options); optionsRef.current = options; const intentsRef = useRef(new Map>()); + const mountedRef = useRef(false); const [overlayBySession, setOverlayBySession] = useState>({}); + const isEqual = useCallback( + (left: Value, right: Value): boolean => + (optionsRef.current.isEqual ?? Object.is)(left, right), + [], + ); + const setOverlay = useCallback((sessionId: string, value: Value | undefined): void => { setOverlayBySession((current) => { if (value !== undefined) { - if (Object.is(current[sessionId], value)) return current; + if (sessionId in current && isEqual(current[sessionId]!, value)) return current; return { ...current, [sessionId]: value }; } if (!(sessionId in current)) return current; @@ -65,7 +95,7 @@ export function useSessionSettingIntent( delete next[sessionId]; return next; }); - }, []); + }, [isEqual]); const reconcile = useCallback((sessionId: string): void => { const intent = intentsRef.current.get(sessionId); @@ -79,64 +109,92 @@ export function useSessionSettingIntent( for (const sessionId of intentsRef.current.keys()) reconcile(sessionId); }, [options.catalogRevision, reconcile]); - const request = useCallback(async (sessionId: string, value: Value): Promise => { + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + const intents = intentsRef.current; + intentsRef.current = new Map(); + for (const intent of intents.values()) intent.resolveCompletion(false); + }; + }, []); + + const request = useCallback((sessionId: string, value: Value): Promise => { + if (!mountedRef.current) return Promise.resolve(false); const existing = intentsRef.current.get(sessionId); if (existing) { + if (!existing.inFlight && existing.committed !== undefined && isEqual(existing.committed, value)) { + return Promise.resolve(true); + } existing.desired = value; setOverlay(sessionId, value); - if (existing.inFlight) return true; + if (existing.inFlight) return existing.completion; } - const intent = existing ?? { desired: value, inFlight: false }; + const intent = createIntent(value, existing); intentsRef.current.set(sessionId, intent); - intent.desired = value; - intent.inFlight = true; setOverlay(sessionId, value); - let succeeded = true; - while (intentsRef.current.get(sessionId) === intent) { - const attempted = intent.desired; - let committed = false; - try { - committed = await optionsRef.current.write(sessionId, attempted); - } catch (error) { - optionsRef.current.onWriteError(sessionId, error); - } - - if (intentsRef.current.get(sessionId) !== intent) return false; - if (committed) { - intent.committed = attempted; - intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; - setOverlay(sessionId, attempted); - // Refresh is only a convergence nudge. A read failure cannot undo a - // Host commit or strand the latest-intent worker. + void (async () => { + let terminalSucceeded = false; + while (mountedRef.current && intentsRef.current.get(sessionId) === intent) { + const attempted = intent.desired; + let committed = false; + let writeError: unknown; try { - await optionsRef.current.refreshCatalog(); - } catch {} - } else { - succeeded = false; - if (Object.is(intent.desired, attempted)) { + committed = await optionsRef.current.write(sessionId, attempted); + } catch (error) { + writeError = error; + } + + if (!mountedRef.current || intentsRef.current.get(sessionId) !== intent) return; + if (committed) { + intent.committed = attempted; + intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; + if (isEqual(intent.desired, attempted)) { + setOverlay(sessionId, attempted); + } + // Refresh is only a convergence nudge. A read failure cannot undo a + // Host commit or strand the latest-intent worker. + try { + void optionsRef.current.refreshCatalog().catch(() => {}); + } catch {} + if (isEqual(intent.desired, attempted)) { + terminalSucceeded = true; + break; + } + continue; + } + if (isEqual(intent.desired, attempted)) { + try { + optionsRef.current.onWriteError(sessionId, writeError, intent.desired); + } catch {} + if (!mountedRef.current || intentsRef.current.get(sessionId) !== intent) return; + if (!isEqual(intent.desired, attempted)) continue; setOverlay(sessionId, intent.committed); break; } } - if (Object.is(intent.desired, attempted)) break; - } - if (intentsRef.current.get(sessionId) === intent) { - intent.inFlight = false; - if (intent.committedAtCatalogRevision === undefined) { - intentsRef.current.delete(sessionId); - setOverlay(sessionId, undefined); - } else { - reconcile(sessionId); + if (mountedRef.current && intentsRef.current.get(sessionId) === intent) { + intent.inFlight = false; + if (intent.committedAtCatalogRevision === undefined) { + intentsRef.current.delete(sessionId); + setOverlay(sessionId, undefined); + } else { + reconcile(sessionId); + } + intent.resolveCompletion(terminalSucceeded); } - } - return succeeded; - }, [reconcile, setOverlay]); + })(); + + return intent.completion; + }, [isEqual, reconcile, setOverlay]); const clear = useCallback((sessionId: string): void => { + const intent = intentsRef.current.get(sessionId); intentsRef.current.delete(sessionId); + intent?.resolveCompletion(false); setOverlay(sessionId, undefined); }, [setOverlay]); 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..8b5ff346d5 --- /dev/null +++ b/packages/ui/src/__tests__/chat-model-switcher.test.tsx @@ -0,0 +1,189 @@ +/* + * 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, + ThinkingLevelSelector, +} 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 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(() => { + (revertRow as HTMLElement).click(); + }); + + assert.deepEqual(changes, [{ + llmConnectionSlug: 'connection-a', + model: 'shared-model', + }]); +}); + +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', + 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..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 =