From 029b6e47d6c65ada665a99db61fa841ca43d06f7 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:13:53 +0800 Subject: [PATCH 01/10] fix(desktop): move session settings behind feature boundary Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 66 +-- ...app-shell-session-settings-actions.test.ts | 389 ------------------ .../app-shell-session-ui-state.test.ts | 10 +- .../session-health-recovery-flow.test.ts | 1 + .../__tests__/session-mode-ipc-main.test.ts | 37 ++ ...session-model-configuration-intent.test.ts | 65 +++ .../__tests__/session-setting-intent.test.ts | 110 ++++- .../session-settings-controller.test.ts | 280 +++++++++++++ .../session-settings-services-adapter.test.ts | 59 +++ .../runtime-host-session-catalog-ipc-main.ts | 22 +- apps/desktop/src/preload/bridge-contract.d.ts | 7 +- apps/desktop/src/preload/preload.ts | 9 +- .../app-shell-session-settings-actions.ts | 240 ----------- .../renderer/app-shell-session-ui-state.ts | 16 +- apps/desktop/src/renderer/app-shell.tsx | 155 ++++--- .../composition/desktop-feature-services.tsx | 23 +- .../features/session-settings/index.ts | 22 + .../features/session-settings/ports.ts | 33 ++ .../session-settings/services-context.tsx | 40 ++ .../session-model-configuration-intent.ts | 59 +++ .../features/session-settings/testing.ts | 24 ++ .../use-session-setting-intent.ts | 165 ++++++++ .../tools/side-chat/quote-companion-panel.tsx | 1 - .../tools/side-chat/use-quote-companion.ts | 71 +++- .../src/renderer/locales/shell-copy.ts | 106 ++--- .../create-session-settings-services.ts | 35 ++ .../use-app-shell-session-ui-reads.ts | 6 - .../renderer/use-session-setting-intent.ts | 144 ------- .../src/renderer/use-shell-chat-model.ts | 33 +- .../composer-model-picker-recovery.test.tsx | 33 +- packages/ui/src/chat-model-switcher.tsx | 113 +++-- packages/ui/src/chat-view.tsx | 1 - packages/ui/src/composer.tsx | 7 +- packages/ui/src/index.ts | 1 + .../ui/src/session-setting-intent.test.ts | 128 ++++++ packages/ui/src/session-setting-intent.ts | 253 ++++++++++++ scripts/check-app-shell-hooks.mjs | 4 +- 37 files changed, 1664 insertions(+), 1104 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-model-configuration-intent.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-settings-controller.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts delete mode 100644 apps/desktop/src/renderer/app-shell-session-settings-actions.ts create mode 100644 apps/desktop/src/renderer/features/session-settings/index.ts create mode 100644 apps/desktop/src/renderer/features/session-settings/ports.ts create mode 100644 apps/desktop/src/renderer/features/session-settings/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/session-settings/session-model-configuration-intent.ts create mode 100644 apps/desktop/src/renderer/features/session-settings/testing.ts create mode 100644 apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts create mode 100644 apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts delete mode 100644 apps/desktop/src/renderer/use-session-setting-intent.ts create mode 100644 packages/ui/src/session-setting-intent.test.ts create mode 100644 packages/ui/src/session-setting-intent.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2fc103c086..ac1cead0b1 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -17,7 +17,6 @@ "src/renderer/app-shell-project-actions.ts", "src/renderer/app-shell-revision-actions.ts", "src/renderer/app-shell-session-events.ts", - "src/renderer/app-shell-session-settings-actions.ts", "src/renderer/app-shell-session-start-actions.ts", "src/renderer/app-shell-session-ui-state.ts", "src/renderer/app-shell-stop-action.ts", @@ -237,7 +236,6 @@ "src/renderer/use-onboarding-snapshot.ts", "src/renderer/use-project-context.ts", "src/renderer/use-session-collaboration-dialog.ts", - "src/renderer/use-session-setting-intent.ts", "src/renderer/use-settings-modal.ts", "src/renderer/use-shell-appearance.ts", "src/renderer/use-shell-chat-model.ts", @@ -714,34 +712,6 @@ "importSpecifiers": 23, "nonTriviaTokens": 3042 }, - "src/renderer/app-shell-session-settings-actions.ts": { - "importDeclarations": 9, - "bridgePaths": { - "window.maka.sessions.setModel": 1, - "window.maka.sessions.setPermissionMode": 1, - "window.maka.sessions.setThinkingLevel": 1 - }, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [ - "createAppShellSessionSettingsActions" - ], - "dependencyPaths": { - "../preload/bridge-contract.js": 1, - "./app-shell-session-ui-state.js": 1, - "./locales/shell-copy.js": 1, - "@maka/core/llm-connections": 1, - "@maka/core/model-thinking": 1, - "@maka/core/permission": 1, - "@maka/core/session": 1, - "@maka/core/settings": 1, - "@maka/core/ui-locale": 1 - }, - "importSpecifiers": 11, - "nonTriviaTokens": 1267 - }, "src/renderer/app-shell-session-start-actions.ts": { "importDeclarations": 7, "bridgePaths": { @@ -788,7 +758,7 @@ "react": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 1223 + "nonTriviaTokens": 1183 }, "src/renderer/app-shell-stop-action.ts": { "importDeclarations": 4, @@ -863,7 +833,7 @@ "nonTriviaTokens": 1425 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 107, + "importDeclarations": 105, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -888,7 +858,6 @@ "window.maka.sessions.reorderQueueEntries": 1, "window.maka.sessions.retractQueueEntry": 1, "window.maka.sessions.setCollaborationMode": 1, - "window.maka.sessions.setOrchestrationMode": 1, "window.maka.sessions.subscribeActiveInteractions": 1, "window.maka.sessions.updateQueueEntry": 1, "window.maka.settings.getClient": 1, @@ -933,7 +902,7 @@ "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, - "useSessionSettingIntent": 2, + "useSessionSettingIntent": 1, "useSettingsModal": 1, "useShellAppearance": 1, "useShellChatModel": 1, @@ -943,7 +912,7 @@ "useShellResume": 1, "useShellRunUpdates": 1, "useShellSearch": 1, - "useStableActions": 7, + "useStableActions": 6, "useState": 17, "useSystemUiLocale": 1, "useTaskEntryController": 1, @@ -970,7 +939,6 @@ "./app-shell-overlays": 1, "./app-shell-revision-actions": 1, "./app-shell-session-events": 1, - "./app-shell-session-settings-actions": 1, "./app-shell-session-start-actions": 1, "./app-shell-stop-action": 1, "./app-shell-turn-actions": 1, @@ -990,6 +958,7 @@ "./features/goals": 1, "./features/module-hub": 1, "./features/session-navigation": 1, + "./features/session-settings": 1, "./features/task-entry": 1, "./features/workbar": 1, "./follow-up-submit-routing": 1, @@ -1020,12 +989,10 @@ "./use-app-shell-session-ui-reads": 1, "./use-app-shell-session-workspace": 1, "./use-composer-attachments": 1, - "./use-external-store-selector": 1, "./use-new-task-choice": 1, "./use-onboarding-snapshot": 1, "./use-project-context": 1, "./use-session-collaboration-dialog": 1, - "./use-session-setting-intent": 1, "./use-settings-modal": 1, "./use-shell-appearance": 1, "./use-shell-chat-model": 1, @@ -1062,7 +1029,7 @@ "react": 1 }, "importSpecifiers": 187, - "nonTriviaTokens": 15908 + "nonTriviaTokens": 15887 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, @@ -1117,7 +1084,7 @@ "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { - "useExternalStoreSelector": 9 + "useExternalStoreSelector": 7 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -1129,7 +1096,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 391 + "nonTriviaTokens": 329 }, "src/renderer/use-app-shell-session-workspace.ts": { "importDeclarations": 11, @@ -2062,7 +2029,6 @@ "actionFactories": [], "dependencyPaths": { "@maka/core/goal": 1, - "@maka/core/model-thinking": 1, "@maka/core/permission": 1, "@maka/core/redaction": 1, "@maka/core/settings": 1, @@ -4994,21 +4960,6 @@ "react": 1 } }, - "src/renderer/use-session-setting-intent.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useEffect": 1, - "useRef": 2, - "useState": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "react": 1 - } - }, "src/renderer/use-settings-modal.ts": { "bridgePaths": {}, "environmentCapabilities": { @@ -5665,7 +5616,6 @@ "legacyPaths": [ "src/renderer/app-shell-context-compaction.ts", "src/renderer/app-shell-revision-actions.ts", - "src/renderer/app-shell-session-settings-actions.ts", "src/renderer/app-shell-stop-action.ts", "src/renderer/app-shell-turn-actions.ts" ] diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts deleted file mode 100644 index fd92a2238f..0000000000 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ /dev/null @@ -1,389 +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'; -import type { SessionPendingClaim } from '../../renderer/app-shell-session-ui-state.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', - llmConnectionId: 'connection-1', - llmConnectionSlug: 'e2e', - connectionLocked: true, - model: 'claude-sonnet', - permissionMode: 'ask', - runtimeHostId: 'host-local', - profileId: 'local', - profileName: 'Local', - profileKind: 'local', - }; -} - -/** The store's claim semantics over a plain map the assertions can read. */ -function pendingClaimOver(state: Record): SessionPendingClaim { - return { - claim(key) { - if (state[key] === true) return false; - state[key] = true; - return true; - }, - release(key) { - delete state[key]; - }, - }; -} - -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 permissionModePending: Record = {}; - const sessionModelPending: Record = {}; - const modelCalls: string[] = []; - const permissionCalls: string[] = []; - const thinkingCalls: string[] = []; - const errors: string[] = []; - const errorDescriptions: Array = []; - 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 ?? [], - permissionModePending: pendingClaimOver(permissionModePending), - sessionModelPending: pendingClaimOver(sessionModelPending), - refreshSessions: async () => sessions, - saveComposerDefaults: () => undefined, - sessionsRef, - setNewTaskPermissionMode: (mode) => void newTaskPermissionModes.push(mode), - toastApi: { - success: (title, description) => successes.push({ title, description }), - error: (title, description, _details, target) => { - errors.push(title); - errorDescriptions.push(description); - errorTargets.push(target); - }, - confirm: options.confirm ?? (async () => true), - }, - }); - - return { - actions, - activeIdRef, - errors, - errorDescriptions, - errorTargets, - modelCalls, - modelResult, - newTaskPermissionModes, - permissionModePending, - sessionModelPending, - 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({ - llmConnectionId: 'connection-1', - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - await harness.actions.setSessionThinkingLevel('high'); - - assert.deepEqual(harness.modelCalls, ['session-a']); - assert.deepEqual(harness.thinkingCalls, []); - assert.equal(harness.sessionModelPending['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({ - llmConnectionId: 'connection-1', - 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({ - llmConnectionId: 'connection-1', - 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({ - llmConnectionId: 'connection-1', - llmConnectionSlug: 'relay', - model: 'claude-sonnet', - }); - harness.modelResult.resolve({ - ...session('session-a'), - llmConnectionId: 'connection-1', - 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({ - llmConnectionId: 'connection-1', - 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(Object.keys(harness.sessionModelPending), ['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({ - llmConnectionId: 'connection-1', - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - - assert.deepEqual(harness.thinkingCalls, ['session-a']); - assert.deepEqual(harness.modelCalls, []); - assert.equal(harness.sessionModelPending['session-a'], true); - - harness.thinkingResult.resolve(session('session-a')); - await thinkingChange; - assert.equal(harness.sessionModelPending['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.sessionModelPending['session-a'], undefined); - assert.equal(harness.errors.length, 1); - assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); - - const modelChange = harness.actions.setSessionModel({ - llmConnectionId: 'connection-1', - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - assert.deepEqual(harness.modelCalls, ['session-a']); - harness.modelResult.resolve(session('session-a')); - await modelChange; - }); - - it('points a failed account-and-model switch at credential recovery', async () => { - const harness = createHarness(); - - const modelChange = harness.actions.setSessionModel({ - llmConnectionId: 'connection-1', - llmConnectionSlug: 'e2e', - model: 'claude-opus', - }); - harness.modelResult.reject(new Error('fixture failure')); - await modelChange; - - assert.match(harness.errorDescriptions[0] ?? '', /设置 · 模型/); - assert.match(harness.errorDescriptions[0] ?? '', /登录或 API Key/); - }); -}); 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..696071e638 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,12 @@ describe('session live run display state', () => { }); describe('app shell session UI state controller', () => { + it('does not mirror session-setting writes into UI pending state', () => { + const state = createInitialAppShellSessionUiState(); + assert.equal('pendingPermissionModeBySession' in state, false); + assert.equal('pendingSessionModelBySession' in state, false); + }); + it('selects background terminal sessions without cutting off the active handoff', () => { const sessions = [ { id: 'running', status: 'running' }, @@ -183,8 +187,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__/session-health-recovery-flow.test.ts b/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts index d8c0455f97..71857a5bba 100644 --- a/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts +++ b/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts @@ -114,6 +114,7 @@ function RecoveryFlow(props: { defaultConnection: CONNECTION.slug, newTaskKey: 'test-draft', activeSession: LEGACY_SESSION, + sessionHealthSession: LEGACY_SESSION, persistedComposerDefaults: null, usePersistedComposerDefaults: false, connectionSnapshotReady: props.snapshotReady, 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..b9bd1b7d6a 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,43 @@ 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', { + llmConnectionId: 'openai-id', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + thinkingLevel: 'high', + }); + + assert.deepEqual(patches, [{ + modelTarget: { + kind: 'explicit', + connectionId: 'openai-id', + connectionSlug: 'openai-main', + model: 'gpt-5', + }, + thinkingLevel: 'high', + }]); +}); + +test('compound model configuration requires an explicit thinking level', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await assert.rejects( + ipc.invoke('sessions:setModelConfiguration', 'session-1', { + llmConnectionId: 'openai-id', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }) 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..9b5b46e99b --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-model-configuration-intent.test.ts @@ -0,0 +1,65 @@ +/* + * 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/features/session-settings/testing.js'; + +const modelA = { + llmConnectionId: 'openai-id', + llmConnectionSlug: 'openai-main', + model: 'model-a', +}; +const modelB = { + llmConnectionId: 'anthropic-id', + 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('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-setting-intent.test.ts b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts index 8b5fdebacc..562bdc5d8d 100644 --- a/apps/desktop/src/main/__tests__/session-setting-intent.test.ts +++ b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts @@ -22,12 +22,20 @@ import { afterEach, test } from 'node:test'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import { useSessionSettingIntent } from '../../renderer/use-session-setting-intent.js'; +import { useSessionSettingIntent } from '@maka/ui'; type SessionSettingIntentController = ReturnType< - typeof useSessionSettingIntent + typeof useSessionSettingIntent<{ setting: Value }> >; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + const originalGlobals = { document: globalThis.document, window: globalThis.window, @@ -74,7 +82,7 @@ test('Runtime leaving Plan after approval supersedes the committed Plan overlay' await render(0, false); await act(async () => { - await controller?.request('session-1', true); + await controller?.request('setting', 'session-1', true); }); assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'true'); @@ -86,6 +94,67 @@ test('Runtime leaving Plan after approval supersedes the committed Plan overlay' assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'false'); }); +test('rapid requests share the worker and settle only after the latest value commits', async () => { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + const writes: Array<{ value: string; result: ReturnType> }> = []; + let controller: SessionSettingIntentController | undefined; + await act(async () => { + root.render(createElement(LatestIntentHarness, { + capture: (next) => { + controller = next; + }, + write: (_sessionId, value) => { + const result = deferred(); + writes.push({ value, result }); + return result.promise; + }, + })); + }); + + let first!: Promise; + let latest!: Promise; + await act(async () => { + first = controller!.request('setting', 'session-1', 'first'); + latest = controller!.request('setting', 'session-1', 'latest'); + }); + let latestSettled = false; + void latest.then(() => { + latestSettled = true; + }); + await Promise.resolve(); + + assert.equal(latestSettled, false); + assert.deepEqual(writes.map((entry) => entry.value), ['first']); + assert.equal(container.querySelector('output')?.getAttribute('data-value'), 'latest'); + + await act(async () => { + writes[0]!.result.resolve(true); + await writes[0]!.result.promise; + }); + assert.deepEqual(writes.map((entry) => entry.value), ['first', 'latest']); + assert.equal(latestSettled, false); + + await act(async () => { + writes[1]!.result.resolve(true); + await Promise.all([first, latest]); + }); + assert.equal(latestSettled, true); + assert.equal(await first, true); + assert.equal(await latest, true); +}); + function Harness({ catalogRevision, catalogValue, @@ -95,16 +164,43 @@ function Harness({ catalogValue: boolean; capture(controller: SessionSettingIntentController): void; }) { - const controller = useSessionSettingIntent({ + const controller = useSessionSettingIntent<{ setting: boolean }>({ catalogRevision, - write: async () => true, refreshCatalog: async () => { throw new Error('catalog unavailable'); }, - onWriteError: () => {}, + channels: { + setting: { + write: async () => true, + onWriteError: () => {}, + }, + }, + }); + capture(controller); + return createElement('output', { + 'data-value': (controller.overlayByChannel.setting['session-1'] ?? catalogValue).toString(), + }); +} + +function LatestIntentHarness({ + capture, + write, +}: { + capture(controller: SessionSettingIntentController): void; + write(sessionId: string, value: string): Promise; +}) { + const controller = useSessionSettingIntent<{ setting: string }>({ + catalogRevision: 0, + refreshCatalog: async () => {}, + channels: { + setting: { + write, + onWriteError: () => {}, + }, + }, }); capture(controller); return createElement('output', { - 'data-value': (controller.overlayBySession['session-1'] ?? catalogValue).toString(), + 'data-value': controller.overlayByChannel.setting['session-1'], }); } diff --git a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts new file mode 100644 index 0000000000..aa8a21768f --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts @@ -0,0 +1,280 @@ +/* + * 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 { SessionSummary } from '@maka/core/session'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { + SessionSettingsServicesProvider, + type SessionSettingsServices, + useSessionSettingIntent, +} from '../../renderer/features/session-settings/index.js'; + +type Controller = ReturnType>; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + Node: globalThis.Node, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test('rejects non-chat permission modes before confirmation or persistence', async () => { + let permissionWrites = 0; + let draftWrites = 0; + let confirmations = 0; + const { controller } = await mountController({ + services: createServices({ + setPermissionMode: async () => { + permissionWrites += 1; + return {} as SessionSummary; + }, + }), + setNewTaskPermissionMode: () => { + draftWrites += 1; + }, + confirmBypass: async () => { + confirmations += 1; + return true; + }, + }); + + let accepted = true; + await act(async () => { + accepted = await controller().setPermissionMode('explore'); + }); + + assert.equal(accepted, false); + assert.equal(permissionWrites, 0); + assert.equal(draftWrites, 0); + assert.equal(confirmations, 0); +}); + +test('rejects non-chat permission modes before writing an existing Session', async () => { + let permissionWrites = 0; + const { controller } = await mountController({ + owner: { sessionId: 'session-1' }, + services: createServices({ + setPermissionMode: async () => { + permissionWrites += 1; + return {} as SessionSummary; + }, + }), + }); + + let accepted = true; + await act(async () => { + accepted = await controller().setPermissionMode('explore'); + }); + + assert.equal(accepted, false); + assert.equal(permissionWrites, 0); +}); + +test('persists a model selection as one compound configuration and saves its default', async () => { + const writes: unknown[] = []; + const savedDefaults: unknown[] = []; + const { controller } = await mountController({ + services: createServices({ + setModelConfiguration: async (sessionId, input) => { + writes.push({ sessionId, input }); + return { + llmConnectionId: input.llmConnectionId, + llmConnectionSlug: input.llmConnectionSlug, + model: input.model, + thinkingLevel: input.thinkingLevel, + } as SessionSummary; + }, + }), + saveComposerDefaults: (model) => savedDefaults.push(model), + }); + + let committed = false; + await act(async () => { + committed = await controller().setSessionModel('session-1', { + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai', + model: 'gpt-5', + }); + }); + + assert.equal(committed, true); + assert.deepEqual(writes, [{ + sessionId: 'session-1', + input: { + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai', + model: 'gpt-5', + thinkingLevel: null, + }, + }]); + assert.deepEqual(savedDefaults, [{ + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai', + model: 'gpt-5', + }]); +}); + +test('persists a thinking selection through the same compound configuration service', async () => { + const writes: unknown[] = []; + const savedDefaults: unknown[] = []; + const { controller } = await mountController({ + sessions: [{ + id: 'session-1', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai', + model: 'gpt-5', + } as SessionSummary], + services: createServices({ + setModelConfiguration: async (sessionId, input) => { + writes.push({ sessionId, input }); + return { + llmConnectionId: input.llmConnectionId, + llmConnectionSlug: input.llmConnectionSlug, + model: input.model, + thinkingLevel: input.thinkingLevel, + } as SessionSummary; + }, + }), + saveComposerDefaults: (model) => savedDefaults.push(model), + }); + + let committed = false; + await act(async () => { + committed = await controller().setSessionThinkingLevel('session-1', 'high'); + }); + + assert.equal(committed, true); + assert.deepEqual(writes, [{ + sessionId: 'session-1', + input: { + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai', + model: 'gpt-5', + thinkingLevel: 'high', + }, + }]); + assert.deepEqual(savedDefaults, []); +}); + +async function mountController(overrides: { + services?: SessionSettingsServices; + owner?: { sessionId?: string }; + sessions?: readonly SessionSummary[]; + setNewTaskPermissionMode?(mode: 'ask' | 'bypass'): void; + confirmBypass?(): Promise; + saveComposerDefaults?(model: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void; +} = {}): Promise<{ controller(): Controller }> { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + let captured: Controller | undefined; + + await act(async () => { + root.render(createElement( + SessionSettingsServicesProvider, + { services: overrides.services ?? createServices() }, + createElement(Harness, { + capture: (controller: Controller) => { + captured = controller; + }, + owner: overrides.owner ?? {}, + sessions: overrides.sessions ?? [], + setNewTaskPermissionMode: overrides.setNewTaskPermissionMode ?? (() => {}), + confirmBypass: overrides.confirmBypass ?? (async () => true), + saveComposerDefaults: overrides.saveComposerDefaults ?? (() => {}), + }), + )); + }); + + return { + controller: () => { + assert.ok(captured); + return captured; + }, + }; +} + +function Harness(props: { + capture(controller: Controller): void; + owner: { sessionId?: string }; + sessions: readonly SessionSummary[]; + setNewTaskPermissionMode(mode: 'ask' | 'bypass'): void; + confirmBypass(): Promise; + saveComposerDefaults(model: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void; +}) { + const controller = useSessionSettingIntent({ + catalogRevision: 0, + isActiveSession: () => true, + sessions: props.sessions, + newTaskPermissionMode: 'ask', + refreshCatalog: async () => {}, + saveComposerDefaults: props.saveComposerDefaults, + writeFailureCopy: () => ({ title: 'failed', description: 'failed' }), + showSessionError: () => {}, + planMode: { write: async () => true }, + captureOwner: () => props.owner, + isOwnerActive: () => true, + setNewTaskPermissionMode: props.setNewTaskPermissionMode, + confirmBypass: props.confirmBypass, + }); + props.capture(controller); + return null; +} + +function createServices( + overrides: Partial = {}, +): SessionSettingsServices { + return { + setModelConfiguration: async () => ({} as SessionSummary), + setPermissionMode: async () => ({} as SessionSummary), + setOrchestrationMode: async () => ({} as SessionSummary), + ...overrides, + }; +} diff --git a/apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts b/apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts new file mode 100644 index 0000000000..2eba5950fb --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts @@ -0,0 +1,59 @@ +/* + * 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 type { MakaBridge } from '../../preload/bridge-contract.js'; +import { createDesktopSessionSettingsServices } from '../../renderer/platform/desktop/create-session-settings-services.js'; + +test('maps session setting services to the existing compound Desktop bridge', async () => { + const calls: Array<{ name: string; args: unknown[] }> = []; + const sessions = new Proxy({}, { + get: (_target, property) => (...args: unknown[]) => { + calls.push({ name: String(property), args }); + return Promise.resolve({}); + }, + }); + const services = createDesktopSessionSettingsServices({ + sessions, + } as unknown as MakaBridge); + + await services.setModelConfiguration('session-1', { + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai', + model: 'gpt-5', + thinkingLevel: 'high', + }); + await services.setPermissionMode('session-1', 'bypass'); + await services.setOrchestrationMode('session-1', 'swarm'); + + assert.deepEqual(calls, [ + { + name: 'setModelConfiguration', + args: ['session-1', { + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai', + model: 'gpt-5', + thinkingLevel: 'high', + }], + }, + { name: 'setPermissionMode', args: ['session-1', 'bypass'] }, + { name: 'setOrchestrationMode', args: ['session-1', 'swarm'] }, + ]); +}); 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 4abdaa5ff7..f6d185d94f 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'; @@ -204,10 +204,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)}`); @@ -366,6 +370,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 99b2c77a63..3dc690e1ca 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1196,7 +1196,12 @@ export interface MakaBridge { executionId: string; }>; abandonPlanExecution(sessionId: string, executionId: string): Promise; - setModel(sessionId: string, input: { llmConnectionId: string; llmConnectionSlug: string; model: string }): Promise; + setModelConfiguration(sessionId: string, input: { + llmConnectionId: string; + 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 48503d5716..687a42a2d0 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2210,8 +2210,13 @@ const makaBridge = { abandonPlanExecution(sessionId: string, executionId: string): Promise { return invokeProjectedSessionRuntimeHost('plan-mode:abandonExecution', sessionId, executionId); }, - setModel(sessionId: string, input: { llmConnectionId: string; llmConnectionSlug: string; model: string }): Promise { - return invokeSessionSummary('sessions:setModel', sessionId, input); + setModelConfiguration(sessionId: string, input: { + llmConnectionId: string; + 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-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts deleted file mode 100644 index aa03c1bae1..0000000000 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ /dev/null @@ -1,240 +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'; -import type { SessionPendingClaim } from './app-shell-session-ui-state.js'; - -type RefBox = { current: T }; - -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: { - llmConnectionId: string; - llmConnectionSlug: string; - model: string; - }): Promise; - setSessionThinkingLevel(level: ThinkingLevel | undefined): Promise; -} - -export function createAppShellSessionSettingsActions(deps: { - uiLocale: UiLocale; - activeIdRef: RefBox; - connections: readonly LlmConnection[]; - messages: readonly StoredMessage[]; - permissionModePending: SessionPendingClaim; - sessionModelPending: SessionPendingClaim; - refreshSessions: () => Promise; - saveComposerDefaults: (patch: { - model: { llmConnectionId: string; llmConnectionSlug: string; model: string }; - }) => void; - sessionsRef: RefBox; - /** Persists the chat default; awaited so a failure surfaces as one. */ - setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void | Promise; - toastApi: ToastApi; -}): AppShellSessionSettingsActions { - const { - uiLocale, - activeIdRef, - connections, - messages, - permissionModePending, - sessionModelPending, - refreshSessions, - saveComposerDefaults, - sessionsRef, - setNewTaskPermissionMode, - toastApi, - } = deps; - const copy = getShellCopy(uiLocale).sessionSettingsActions; - - 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; - // No session means the chat default, which has no row to mark pending. It - // still needs a key of its own so two rapid switches cannot both run. - const pendingKey = sessionId ?? '__global_permission_mode__'; - // Claimed before the confirm rather than after it: the dialog is part of - // the change, so a second click while it is open must not open a second - // one. The cost is that the control reads as pending while the user - // decides, which is what is actually true. - if (!permissionModePending.claim(pendingKey)) return false; - if ( - mode === 'bypass' && - !(await toastApi.confirm({ - title: copy.bypassConfirmTitle, - description: copy.bypassConfirmDescription, - confirmLabel: copy.bypassConfirmLabel, - cancelLabel: copy.bypassCancelLabel, - destructive: true, - })) - ) { - permissionModePending.release(pendingKey); - return false; - } - - 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 { - permissionModePending.release(pendingKey); - } - } - - async function setSessionModel(input: { - llmConnectionId: string; - 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 (!sessionModelPending.claim(sessionId)) return; - 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) { - const detail = localizedShellErrorMessage(error, copy.modelFallback, uiLocale); - toastApi.error( - copy.modelFailedTitle, - `${detail} ${copy.modelRecoveryHint}`, - undefined, - { sessionId }, - ); - } - } finally { - sessionModelPending.release(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 (!sessionModelPending.claim(sessionId)) return; - 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 { - sessionModelPending.release(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 aa283d9f96..8c91c09c69 100644 --- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -34,8 +34,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 @@ -50,9 +48,7 @@ type AppShellSessionUiStateMapKey = keyof AppShellSessionUiState; /** The maps that record nothing but "an action is in flight for this key". */ type BooleanMapKey = | 'messageRetryPendingBySession' - | 'stopPendingBySession' - | 'pendingPermissionModeBySession' - | 'pendingSessionModelBySession'; + | 'stopPendingBySession'; export interface SessionPendingClaim { /** Marks `key` in flight. Returns false — a no-op — if it already was. */ @@ -69,8 +65,6 @@ const SESSION_UI_MAP_KEYS = [ 'shellRunUpdatesBySession', 'interactionBySession', 'messageQueueBySession', - 'pendingPermissionModeBySession', - 'pendingSessionModelBySession', ] as const satisfies readonly AppShellSessionUiStateMapKey[]; type MissingSessionUiMapKey = Exclude; @@ -79,8 +73,8 @@ 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 +// projection. The independently-scoped maps (message load error / retry, 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 @@ -178,7 +172,7 @@ export function createAppShellSessionUiStateController( * reports whether it won, `release` gives it back. * * Both read and write the same map, which is what makes this the only - * representation of "an action is in flight". Each of these four used to be a + * representation of "an action is in flight". Each of these maps used to be a * `Set` ref for the duplicate guard beside a map for the rendered flag, * synchronized by hand at every add and every `finally`, and cleared by two * separate teardown paths that stayed aligned only by ordering. Nothing @@ -206,8 +200,6 @@ export function createAppShellSessionUiStateController( setMessageLoadErrorBySession: createMapSetter('messageLoadErrorBySession'), messageRetryPending: createPendingClaim('messageRetryPendingBySession'), stopPending: createPendingClaim('stopPendingBySession'), - permissionModePending: createPendingClaim('pendingPermissionModeBySession'), - sessionModelPending: createPendingClaim('pendingSessionModelBySession'), setLiveTurnBySession: createMapSetter('liveTurnBySession'), setShellRunUpdatesBySession: createMapSetter('shellRunUpdatesBySession'), setInteractionBySession: createMapSetter('interactionBySession'), diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5d6f5763b8..e1cee24648 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -143,13 +143,18 @@ import { scopeWorkHubSessionsToCoordinationHost } from './workhub-coordination-h import { createDesktopWorkHubSessionPort } from './workhub-session-port.js'; import { createDesktopWorkHubCoordinationPort } from './workhub-coordination-port.js'; import { WorkHubCoordinationStatus, WorkHubSurface } from './workhub-surface.js'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy'; +import { + getShellCopy, + localizedShellErrorMessage, + confirmBypassPermission, + sessionSettingFailureCopy, +} from './locales/shell-copy'; import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; import { getDesktopConversationCopy } from './locales/conversation-copy'; 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 { useSessionSettingIntent } from './features/session-settings'; import { deriveStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; import { useAppShellTurnPresentation } from './app-shell-turn-view-model'; @@ -192,9 +197,7 @@ 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 { useExternalStoreSelector } from './use-external-store-selector'; import { useStableActions } from './use-stable-actions'; import { useActiveSessionEvents, @@ -552,8 +555,6 @@ function AppShellContent({ stopPendingBySession, interactionBySession, messageQueueBySession, - pendingPermissionModeBySession, - pendingSessionModelBySession, streamingSessionIds, activeLiveTurnSnapshot, } = useAppShellSessionUiReads(sessionUiController, activeId); @@ -835,6 +836,40 @@ function AppShellContent({ activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const activeSession = activeCatalogSession; + const sessionSettingIntent = useSessionSettingIntent({ + catalogRevision, + isActiveSession: (sessionId) => activeIdRef.current === sessionId, + sessions, + newTaskPermissionMode, + refreshCatalog: refreshSessions, + saveComposerDefaults: (model) => saveComposerDefaults({ model }), + writeFailureCopy: (setting, error) => sessionSettingFailureCopy(uiLocale, setting, error), + showSessionError, + planMode: { + write: commitPlanMode, + }, + captureOwner: captureComposerImportOwner, + isOwnerActive: isComposerImportOwnerActive, + setNewTaskPermissionMode, + confirmBypass: () => confirmBypassPermission(toastApi, uiLocale), + }); + const { setPermissionMode, setSessionModel, setSessionThinkingLevel } = sessionSettingIntent; + const modelConfigurationOverlay = activeSession + ? sessionSettingIntent.overlays.modelConfiguration[activeSession.id] + : undefined; + const activeSessionForModelControls = activeSession + ? { + ...activeSession, + ...(modelConfigurationOverlay + ? { + llmConnectionId: modelConfigurationOverlay.modelTarget.llmConnectionId, + llmConnectionSlug: modelConfigurationOverlay.modelTarget.llmConnectionSlug, + model: modelConfigurationOverlay.modelTarget.model, + thinkingLevel: modelConfigurationOverlay.thinkingLevel ?? undefined, + } + : {}), + } + : undefined; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; const activeMessageSubmitting = transientMessages.length > 0; const activeDesktopSession = activeSession; @@ -888,13 +923,10 @@ function AppShellContent({ const modelSettingsOwnsComposerHost = composerProfileId !== undefined && composerProfileId === taskEntry.selectors.defaultProfileId; - const modelChangePending = activeId - ? pendingSessionModelBySession[activeId] === true - : false; const modelSwitchAvailability = deriveComposerModelSwitchAvailability({ streaming: turnActive || activeStreamingLive, sessionStatus: activeSession?.status, - pending: modelChangePending, + pending: false, }); const { chatModelChoices, @@ -922,7 +954,8 @@ function AppShellContent({ activationCandidate: modelSettingsOwnsComposerHost ? onboardingActivationCandidate : undefined, - activeSession, + activeSession: activeSessionForModelControls, + sessionHealthSession: activeSession, persistedComposerDefaults, usePersistedComposerDefaults: modelSettingsOwnsComposerHost, defaultThinkingLevel: taskEntry.selectors.selectedHost?.chatDefaults.thinkingLevel, @@ -959,62 +992,9 @@ function AppShellContent({ // session from every session-UI map — the four pending claims included. clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); - planModeIntent.clear(sessionId); - orchestrationModeIntent.clear(sessionId); + sessionSettingIntent.clear(sessionId); } - const { - setPermissionMode, - setSessionModel, - setSessionThinkingLevel, - } = useStableActions(createAppShellSessionSettingsActions, { - uiLocale, - activeIdRef, - connections, - messages, - permissionModePending: sessionUiController.permissionModePending, - sessionModelPending: sessionUiController.sessionModelPending, - refreshSessions, - saveComposerDefaults, - sessionsRef, - setNewTaskPermissionMode, - 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 - // takes over — whether it confirms that value or shows a newer Host change. - const planModeIntent = useSessionSettingIntent({ - catalogRevision, - write: commitPlanMode, - refreshCatalog: refreshSessions, - onWriteError: (sessionId, error) => { - if (activeIdRef.current !== sessionId) return; - showSessionError( - sessionId, - shellCopy.planModeFailedTitle, - localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale), - ); - }, - }); - const orchestrationModeIntent = useSessionSettingIntent({ - catalogRevision, - write: async (sessionId, mode) => { - await window.maka.sessions.setOrchestrationMode(sessionId, mode); - return true; - }, - refreshCatalog: refreshSessions, - onWriteError: (sessionId, error) => { - if (activeIdRef.current !== sessionId) return; - showSessionError( - sessionId, - shellCopy.orchestrationModeFailedTitle, - localizedShellErrorMessage(error, shellCopy.orchestrationModeFallback, uiLocale), - ); - }, - }); - // Stable: the rail's row actions are built from it, and it only reaches // registries and refs that are themselves stable (#4109). /** @@ -1076,7 +1056,7 @@ function AppShellContent({ return Promise.resolve(true); } if (active === activePlanMode) return Promise.resolve(true); - return planModeIntent.request(sessionId, active); + return sessionSettingIntent.setPlanMode(sessionId, active); } /** @@ -1093,7 +1073,7 @@ function AppShellContent({ return Promise.resolve(true); } if (mode === activeOrchestrationMode) return Promise.resolve(true); - return orchestrationModeIntent.request(sessionId, mode); + return sessionSettingIntent.setOrchestrationMode(sessionId, mode); } function setOrchestrationModeActive( @@ -1212,11 +1192,11 @@ function AppShellContent({ // to keep in sync: a Session in Plan with Swarm as its orchestration default // says both, because it is both. const activePlanMode = activeId - ? planModeIntent.overlayBySession[activeId] + ? sessionSettingIntent.overlays.planMode[activeId] ?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan') : newChatPlanModeActive; const activeOrchestrationMode: OrchestrationMode = activeId - ? orchestrationModeIntent.overlayBySession[activeId] + ? sessionSettingIntent.overlays.orchestrationMode[activeId] ?? activeSessionForView?.orchestrationMode ?? 'default' : newChatOrchestrationMode; @@ -1280,7 +1260,10 @@ function AppShellContent({ activeExecutionBoundary, activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newTaskPermissionMode, ); - const activePermissionMode = activeBoundarySurface.permissionMode; + const activePermissionMode = activeId + ? sessionSettingIntent.overlays.permissionMode[activeId] + ?? activeBoundarySurface.permissionMode + : activeBoundarySurface.permissionMode; const planMode = usePlanModeState(sharedSessionActive ? undefined : activeSessionForView); const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({ id: proposal.proposalId, @@ -3006,6 +2989,8 @@ function AppShellContent({ } modelLabel={activeModelLabel ?? newChatModelLabel ?? undefined} activeSession={activeSessionForView} + activeModelConnectionId={activeSessionForModelControls?.llmConnectionId} + activeModelConnectionSlug={activeSessionForModelControls?.llmConnectionSlug} activeModel={activeModel} activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} @@ -3014,10 +2999,14 @@ function AppShellContent({ hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'} renderProviderMark={(type) => } modelSwitchAvailability={modelSwitchAvailability} - 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) => { @@ -3040,7 +3029,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 @@ -3048,15 +3036,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 @@ -3119,8 +3105,9 @@ function AppShellContent({ activeProviderType={activeConnection?.providerType} renderProviderMark={(type) => } modelChoices={chatModelChoices} - modelChangePending={modelChangePending} - onModelChange={sharedSessionActive ? undefined : (input) => setSessionModel(input)} + onModelChange={sharedSessionActive ? undefined : (input) => { + if (activeId) void setSessionModel(activeId, input); + }} userLabel={userLabel} memoryActive={memoryActive} onOpenMemorySettings={sharedSessionActive ? undefined : () => openSettingsSection('memory')} diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 47c188d6e6..7f8c2de661 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -23,6 +23,7 @@ import { ModuleHubServicesProvider } from '../features/module-hub'; import { RuntimeHostManagementServicesProvider } from '../features/runtime-host-management'; import { SessionCollaborationServicesProvider } from '../features/session-collaboration'; import { SessionNavigationServicesProvider } from '../features/session-navigation'; +import { SessionSettingsServicesProvider } from '../features/session-settings'; import { TaskEntryServicesProvider } from '../features/task-entry'; import { WorkbarServicesProvider } from '../features/workbar'; import { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; @@ -30,6 +31,7 @@ import { createDesktopModuleHubServices } from '../platform/desktop/create-modul import { createDesktopRuntimeHostManagementServices } from '../platform/desktop/create-runtime-host-management-services'; import { createDesktopSessionCollaborationServices } from '../platform/desktop/create-session-collaboration-services'; import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services'; +import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services'; import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services'; import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services'; @@ -40,6 +42,7 @@ export function createDesktopFeatureServices() { runtimeHostManagement: createDesktopRuntimeHostManagementServices(), sessionCollaboration: createDesktopSessionCollaborationServices(), sessionNavigation: createDesktopSessionNavigationServices(), + sessionSettings: createDesktopSessionSettingsServices(), taskEntry: createDesktopTaskEntryServices(), workbar: createDesktopWorkbarServices(), }; @@ -53,15 +56,17 @@ export function DesktopFeatureServicesProvider(props: { - - - - - {props.children} - - - - + + + + + + {props.children} + + + + + diff --git a/apps/desktop/src/renderer/features/session-settings/index.ts b/apps/desktop/src/renderer/features/session-settings/index.ts new file mode 100644 index 0000000000..8c4cf84c55 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/index.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export { SessionSettingsServicesProvider } from './services-context.js'; +export { useSessionSettingIntent } from './use-session-setting-intent.js'; +export type { SessionSettingsServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/features/session-settings/ports.ts b/apps/desktop/src/renderer/features/session-settings/ports.ts new file mode 100644 index 0000000000..2bde8d19c4 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/ports.ts @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { OrchestrationMode } from '@maka/core/orchestration'; +import type { SessionSummary } from '@maka/core/session'; +import type { ChatDefaultPermissionMode } from '@maka/core/settings'; +import type { SessionModelTarget } from './session-model-configuration-intent.js'; + +export interface SessionSettingsServices { + setModelConfiguration( + sessionId: string, + input: SessionModelTarget & { thinkingLevel: ThinkingLevel | null }, + ): Promise; + setPermissionMode(sessionId: string, mode: ChatDefaultPermissionMode): Promise; + setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise; +} diff --git a/apps/desktop/src/renderer/features/session-settings/services-context.tsx b/apps/desktop/src/renderer/features/session-settings/services-context.tsx new file mode 100644 index 0000000000..6ac7e6d8f3 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/services-context.tsx @@ -0,0 +1,40 @@ +/* + * 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 { createContext, useContext, type ReactNode } from 'react'; +import type { SessionSettingsServices } from './ports.js'; + +const SessionSettingsServicesContext = createContext(null); + +export function SessionSettingsServicesProvider(props: { + readonly services: SessionSettingsServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useSessionSettingsServices(): SessionSettingsServices { + const services = useContext(SessionSettingsServicesContext); + if (!services) throw new Error('SessionSettingsServicesProvider is missing'); + return services; +} diff --git a/apps/desktop/src/renderer/features/session-settings/session-model-configuration-intent.ts b/apps/desktop/src/renderer/features/session-settings/session-model-configuration-intent.ts new file mode 100644 index 0000000000..9751c495f4 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/session-model-configuration-intent.ts @@ -0,0 +1,59 @@ +/* + * 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 = { + llmConnectionId: string; + 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.llmConnectionId === right.modelTarget.llmConnectionId && + 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; + return modelTarget + ? { modelTarget, thinkingLevel, changedSetting: 'thinking' } + : undefined; +} diff --git a/apps/desktop/src/renderer/features/session-settings/testing.ts b/apps/desktop/src/renderer/features/session-settings/testing.ts new file mode 100644 index 0000000000..3b070d8bee --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/testing.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export { + equalSessionModelConfigurationIntent, + modelConfigurationIntentForModel, + modelConfigurationIntentForThinking, +} from './session-model-configuration-intent.js'; diff --git a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts new file mode 100644 index 0000000000..438ca98097 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts @@ -0,0 +1,165 @@ +/* + * 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 { OrchestrationMode } from '@maka/core/orchestration'; +import type { PermissionMode } from '@maka/core/permission'; +import type { SessionSummary } from '@maka/core/session'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; +import { + isChatDefaultPermissionMode, + type ChatDefaultPermissionMode, +} from '@maka/core/settings'; +import { + useSessionSettingIntent as useSharedSessionSettingIntent, +} from '@maka/ui'; +import { + equalSessionModelConfigurationIntent, + modelConfigurationIntentForModel, + modelConfigurationIntentForThinking, + type SessionModelConfigurationIntent, + type SessionModelTarget, +} from './session-model-configuration-intent.js'; +import { useSessionSettingsServices } from './services-context.js'; + +type SessionSettingValues = { + modelConfiguration: SessionModelConfigurationIntent; + permissionMode: ChatDefaultPermissionMode; + planMode: boolean; + orchestrationMode: OrchestrationMode; +}; + +export function useSessionSettingIntent(input: { + catalogRevision: number; + isActiveSession(sessionId: string): boolean; + sessions: readonly SessionSummary[]; + newTaskPermissionMode: ChatDefaultPermissionMode; + refreshCatalog(): Promise; + saveComposerDefaults(model: SessionModelTarget): void; + writeFailureCopy( + setting: 'model' | 'thinking' | 'permission' | 'plan' | 'orchestration', + error: unknown, + ): { title: string; description: string }; + showSessionError(sessionId: string, title: string, description: string): void; + planMode: { + write(sessionId: string, active: boolean): Promise; + }; + captureOwner(): Owner; + isOwnerActive(owner: Owner): boolean; + setNewTaskPermissionMode(mode: ChatDefaultPermissionMode): void; + confirmBypass(): Promise; +}) { + const services = useSessionSettingsServices(); + const reportWriteError = ( + sessionId: string, + error: unknown, + setting: 'model' | 'thinking' | 'permission' | 'plan' | 'orchestration', + ) => { + if (!input.isActiveSession(sessionId)) return; + const failure = input.writeFailureCopy(setting, error); + input.showSessionError(sessionId, failure.title, failure.description); + }; + const intent = useSharedSessionSettingIntent({ + catalogRevision: input.catalogRevision, + refreshCatalog: input.refreshCatalog, + channels: { + modelConfiguration: { + isEqual: equalSessionModelConfigurationIntent, + write: async (sessionId, configuration) => { + const summary = await services.setModelConfiguration(sessionId, { + ...configuration.modelTarget, + thinkingLevel: configuration.thinkingLevel, + }); + const committed = + summary.llmConnectionId === configuration.modelTarget.llmConnectionId && + summary.llmConnectionSlug === configuration.modelTarget.llmConnectionSlug && + summary.model === configuration.modelTarget.model && + (summary.thinkingLevel ?? null) === configuration.thinkingLevel; + if (committed && configuration.changedSetting === 'model') { + input.saveComposerDefaults(configuration.modelTarget); + } + return committed; + }, + onWriteError: (sessionId, error, attempted) => + reportWriteError(sessionId, error, attempted.changedSetting), + }, + permissionMode: { + write: async (sessionId, mode) => + (await services.setPermissionMode(sessionId, mode)).permissionMode === mode, + onWriteError: (sessionId, error) => reportWriteError(sessionId, error, 'permission'), + }, + planMode: { + write: input.planMode.write, + onWriteError: (sessionId, error) => reportWriteError(sessionId, error, 'plan'), + }, + orchestrationMode: { + write: async (sessionId, mode) => { + await services.setOrchestrationMode(sessionId, mode); + return true; + }, + onWriteError: (sessionId, error) => + reportWriteError(sessionId, error, 'orchestration'), + }, + }, + }); + + return { + clear: intent.clear, + overlays: intent.overlayByChannel, + setSessionModel: (sessionId: string, modelTarget: SessionModelTarget) => + intent.request('modelConfiguration', sessionId, modelConfigurationIntentForModel(modelTarget)), + setSessionThinkingLevel: (sessionId: string, thinkingLevel: ThinkingLevel | null) => { + const pending = intent.overlayByChannel.modelConfiguration[sessionId]; + const session = input.sessions.find((candidate) => candidate.id === sessionId); + const currentModelTarget = session?.llmConnectionId + ? { + llmConnectionId: session.llmConnectionId, + llmConnectionSlug: session.llmConnectionSlug, + model: session.model, + } + : undefined; + const next = modelConfigurationIntentForThinking(currentModelTarget, pending, thinkingLevel); + return next + ? intent.request('modelConfiguration', sessionId, next) + : Promise.resolve(false); + }, + setPermissionMode: async (mode: PermissionMode) => { + if (!isChatDefaultPermissionMode(mode)) return false; + const owner = input.captureOwner(); + const sessionId = owner.sessionId; + const overlay = sessionId ? intent.overlayByChannel.permissionMode[sessionId] : undefined; + const currentMode = sessionId + ? overlay ?? input.sessions.find((session) => session.id === sessionId)?.permissionMode + : input.newTaskPermissionMode; + if (currentMode === mode) { + return sessionId && overlay !== undefined + ? intent.request('permissionMode', sessionId, mode) + : true; + } + if (mode === 'bypass' && !(await input.confirmBypass())) return false; + if (!input.isOwnerActive(owner)) return false; + if (sessionId) return intent.request('permissionMode', sessionId, mode); + input.setNewTaskPermissionMode(mode); + return true; + }, + setPlanMode: (sessionId: string, active: boolean) => + intent.request('planMode', sessionId, active), + setOrchestrationMode: (sessionId: string, mode: OrchestrationMode) => + intent.request('orchestrationMode', sessionId, mode), + }; +} 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 93d4235c3c..71c7a0c7ee 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 @@ -289,7 +289,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 2c6244eb18..c7bb78745e 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 @@ -24,6 +24,7 @@ import { armLiveTurn, reconcileTerminalLiveTurn, useMountedRef, + useSessionSettingIntent, type InteractionQueues, type LiveTurnProjection, } from '@maka/ui'; @@ -63,6 +64,8 @@ import { } from './quote-companion-panel-state.js'; import type { CompanionForkVisibilityEvent } from './quote-companion-visibility.js'; +type QuoteCompanionSettingValues = { permissionMode: PermissionMode }; + type PendingAdmission = { messageId: string; events: SessionEvent[]; @@ -129,7 +132,6 @@ export interface UseQuoteCompanionResult { /** Whether the source and any committed companion can execute their exact model. */ modelReady: boolean; permissionMode: PermissionMode | undefined; - permissionModePending: boolean; regeneratePendingTurnId: string | null; /** A localized, retryable error (fork setup, run error, or a rejected send). */ error: string | null; @@ -226,7 +228,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, ); @@ -244,6 +245,41 @@ 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, + refreshCatalog: async () => { + const sessionId = companionIdRef.current; + if (!sessionId) return; + const sessions = await sideChat.listSessions(); + const next = sessions.find((session) => session.id === sessionId); + if (!mountedRef.current || companionIdRef.current !== sessionId || !next) return; + companionRef.current = next; + setCompanion(next); + setPermissionCatalogRevision((revision) => revision + 1); + }, + channels: { + permissionMode: { + write: async (sessionId, mode) => { + const next = await sideChat.setPermissionMode(sessionId, mode); + if (mountedRef.current && companionIdRef.current === sessionId) { + companionRef.current = next; + setCompanion(next); + } + return next.permissionMode === mode; + }, + onWriteError: () => { + if (mountedRef.current) setError(copyRef.current.errors.respondFailed); + }, + }, + }, + }); + const requestPermissionMode = useCallback( + (sessionId: string, mode: PermissionMode) => + permissionModeIntent.request('permissionMode', sessionId, mode), + [permissionModeIntent.request], + ); + const clearPermissionModeIntent = permissionModeIntent.clear; const setPendingAdmission = useCallback((admission: PendingAdmission | null) => { pendingAdmissionRef.current = admission; @@ -525,6 +561,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan subscriptionReadyRef.current = Promise.resolve(); companionIdRef.current = null; companionRef.current = undefined; + clearPermissionModeIntent(existing.id); setCompanion(undefined); setAllMessages([]); onForkVisibilityChangeRef.current?.({ @@ -608,7 +645,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan forkSetupPromiseRef.current = promise; return promise; }, - [commitFork, mountedRef, panelId, sideChat], + [clearPermissionModeIntent, commitFork, mountedRef, panelId, sideChat], ); const companionModelReady = @@ -946,25 +983,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 requestPermissionMode(id, mode); }, - [mountedRef, permissionModePending, sideChat, turnInFlight], + [requestPermissionMode, turnInFlight], ); const regenerate = useCallback( @@ -1036,7 +1061,10 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan : sourceSession ? { llmConnectionSlug: sourceSession.llmConnectionSlug, model: sourceSession.model } : undefined; - const permissionMode = (companion?.permissionMode ?? + const companionPermissionOverlay = companion?.id + ? permissionModeIntent.overlayByChannel.permissionMode[companion.id] + : undefined; + const permissionMode = (companionPermissionOverlay ?? companion?.permissionMode ?? sourceSession?.permissionMode) as PermissionMode | undefined; const activeInteraction = companionIdRef.current ? activeInteractionFor(interactions, companionIdRef.current) @@ -1056,7 +1084,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan preparing, modelReady: sourceModelReady && companionModelReady, 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 855143e079..dcdb0ded8a 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,23 +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; - modelRecoveryHint: string; - thinkingUpdatedTitle: string; - thinkingDefault: string; - thinkingLabels: Record; thinkingFailedTitle: string; thinkingFallback: string; }; @@ -490,7 +480,6 @@ type ShellCopy = { boundaryUnreadableDetail: string; boundaryUnreadableRetry: string; boundaryUnreadableRetrying: string; - permissionModeChanging: string; permissionModeStreaming: string; permissionModeRunning: string; permissionModeWaiting: string; @@ -981,39 +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: '模型暂时无法切换,请稍后重试。', - modelRecoveryHint: '如果所选连接需要登录或 API Key,请到 设置 · 模型 补齐后重试。', - thinkingUpdatedTitle: '已更新思考级别', - thinkingDefault: '默认', - thinkingLabels: { - off: '关', - minimal: '最少', - low: '低', - medium: '中', - high: '高', - xhigh: '超高', - max: '最高', - }, thinkingFailedTitle: '切换思考级别失败', thinkingFallback: '思考级别暂时无法切换,请稍后重试。', }, @@ -1239,7 +1204,6 @@ const SHELL_COPY_BY_LOCALE = { boundaryUnreadableDetail: '在读到之前,这里暂时不能输入。可以重试,或先切换到别的任务。', boundaryUnreadableRetry: '重试', boundaryUnreadableRetrying: '重试中…', - permissionModeChanging: '权限模式正在切换,完成后再继续操作。', permissionModeStreaming: '当前任务正在流式输出,等结束后再切换权限模式。', permissionModeRunning: '当前任务正在运行,等结束后再切换权限模式。', permissionModeWaiting: '当前有工具调用正在等待确认,处理后再切换权限模式。', @@ -1508,39 +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.', - modelRecoveryHint: 'If the selected connection needs sign-in or an API key, complete it in Settings · Models and try again.', - 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.', }, @@ -1807,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.', @@ -1850,3 +1789,46 @@ export function getShellCopy(locale: UiLocale): ShellCopy { export function localizedShellErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { return locale === 'zh' ? generalizedErrorMessageChinese(error, fallback) : generalizedErrorMessage(error, fallback); } + +export function sessionSettingFailureCopy( + locale: UiLocale, + setting: 'model' | 'thinking' | 'permission' | 'plan' | 'orchestration', + error: unknown, +): { title: string; description: string } { + const copy = getShellCopy(locale); + const failure = setting === 'model' + ? { title: copy.sessionSettingsActions.modelFailedTitle, fallback: copy.sessionSettingsActions.modelFallback } + : setting === 'thinking' + ? { title: copy.sessionSettingsActions.thinkingFailedTitle, fallback: copy.sessionSettingsActions.thinkingFallback } + : setting === 'permission' + ? { title: copy.sessionSettingsActions.permissionFailedTitle, fallback: copy.sessionSettingsActions.permissionFallback } + : setting === 'plan' + ? { title: copy.app.planModeFailedTitle, fallback: copy.app.planModeFallback } + : { title: copy.app.orchestrationModeFailedTitle, fallback: copy.app.orchestrationModeFallback }; + return { + title: failure.title, + description: localizedShellErrorMessage(error, failure.fallback, locale), + }; +} + +export function confirmBypassPermission( + toast: { + confirm(input: { + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; + }): Promise; + }, + locale: UiLocale, +): Promise { + const copy = getShellCopy(locale).sessionSettingsActions; + return toast.confirm({ + title: copy.bypassConfirmTitle, + description: copy.bypassConfirmDescription, + confirmLabel: copy.bypassConfirmLabel, + cancelLabel: copy.bypassCancelLabel, + destructive: true, + }); +} diff --git a/apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts b/apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts new file mode 100644 index 0000000000..b0850c9fe5 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts @@ -0,0 +1,35 @@ +/* + * 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 { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { SessionSettingsServices } from '../../features/session-settings'; + +export type DesktopSessionSettingsBridge = Pick; + +export function createDesktopSessionSettingsServices( + bridge: DesktopSessionSettingsBridge = window.maka, +): SessionSettingsServices { + return { + setModelConfiguration: (sessionId, input) => + bridge.sessions.setModelConfiguration(sessionId, input), + setPermissionMode: (sessionId, mode) => bridge.sessions.setPermissionMode(sessionId, mode), + setOrchestrationMode: (sessionId, mode) => + bridge.sessions.setOrchestrationMode(sessionId, mode), + }; +} 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 654a867d26..ae598025ca 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: useExternalStoreSelector(controller, selectStopPending), interactionBySession: useExternalStoreSelector(controller, selectInteraction), messageQueueBySession: useExternalStoreSelector(controller, selectMessageQueue), - pendingPermissionModeBySession: useExternalStoreSelector(controller, selectPendingPermissionMode), - pendingSessionModelBySession: useExternalStoreSelector(controller, selectPendingSessionModel), streamingSessionIds: useExternalStoreSelector(controller, selectPulseSet, undefined, sessionIdSetsEqual), activeLiveTurnSnapshot: useExternalStoreSelector( controller, diff --git a/apps/desktop/src/renderer/use-session-setting-intent.ts b/apps/desktop/src/renderer/use-session-setting-intent.ts deleted file mode 100644 index bcde3ded5b..0000000000 --- a/apps/desktop/src/renderer/use-session-setting-intent.ts +++ /dev/null @@ -1,144 +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 { useCallback, useEffect, useRef, useState } from 'react'; - -interface SettingIntent { - desired: Value; - committed?: Value; - committedAtCatalogRevision?: number; - inFlight: boolean; -} - -interface SessionSettingIntentOptions { - catalogRevision: number; - write(sessionId: string, value: Value): Promise; - refreshCatalog(): Promise; - onWriteError(sessionId: string, error: unknown): void; -} - -interface SessionSettingIntentController { - overlayBySession: Readonly>; - request(sessionId: string, value: Value): Promise; - clear(sessionId: string): void; -} - -/** - * Owns the gap between a renderer setting intent, its Host commit, and the - * next successful catalog observation. Only the latest desired value is - * written. A failed read retains the committed overlay; any causally newer - * successful snapshot retires it, because the Host may legitimately move on - * again (for example, Runtime leaves Plan after approval). - */ -export function useSessionSettingIntent( - options: SessionSettingIntentOptions, -): SessionSettingIntentController { - const optionsRef = useRef(options); - optionsRef.current = options; - const intentsRef = useRef(new Map>()); - const [overlayBySession, setOverlayBySession] = useState>({}); - - const setOverlay = useCallback((sessionId: string, value: Value | undefined): void => { - setOverlayBySession((current) => { - if (value !== undefined) { - if (Object.is(current[sessionId], value)) return current; - return { ...current, [sessionId]: value }; - } - if (!(sessionId in current)) return current; - const next = { ...current }; - delete next[sessionId]; - return next; - }); - }, []); - - const reconcile = useCallback((sessionId: string): void => { - const intent = intentsRef.current.get(sessionId); - if (!intent || intent.inFlight || intent.committedAtCatalogRevision === undefined) return; - if (optionsRef.current.catalogRevision <= intent.committedAtCatalogRevision) return; - intentsRef.current.delete(sessionId); - setOverlay(sessionId, undefined); - }, [setOverlay]); - - useEffect(() => { - for (const sessionId of intentsRef.current.keys()) reconcile(sessionId); - }, [options.catalogRevision, reconcile]); - - const request = useCallback(async (sessionId: string, value: Value): Promise => { - const existing = intentsRef.current.get(sessionId); - if (existing) { - existing.desired = value; - setOverlay(sessionId, value); - if (existing.inFlight) return true; - } - - const intent = existing ?? { desired: value, inFlight: false }; - 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. - try { - await optionsRef.current.refreshCatalog(); - } catch {} - } else { - succeeded = false; - if (Object.is(intent.desired, attempted)) { - 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); - } - } - return succeeded; - }, [reconcile, setOverlay]); - - const clear = useCallback((sessionId: string): void => { - intentsRef.current.delete(sessionId); - setOverlay(sessionId, undefined); - }, [setOverlay]); - - return { overlayBySession, request, clear }; -} diff --git a/apps/desktop/src/renderer/use-shell-chat-model.ts b/apps/desktop/src/renderer/use-shell-chat-model.ts index 7c7deb42ae..9dbe2bc25e 100644 --- a/apps/desktop/src/renderer/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/use-shell-chat-model.ts @@ -71,6 +71,7 @@ export function useShellChatModel(options: { newTaskKey: string; activationCandidate?: NewChatModelCandidate; activeSession: SessionSummary | undefined; + sessionHealthSession: SessionSummary | undefined; persistedComposerDefaults: ComposerDefaults | null; usePersistedComposerDefaults: boolean; /** Settings → 通用 → 默认思考级别; undefined means "no preference". */ @@ -98,7 +99,17 @@ export function useShellChatModel(options: { setPendingNewChatThinkingLevel: (next: ThinkingLevel | null) => void; sessionHealthNotice: SessionHealthNoticeView | undefined; } { - const { uiLocale, connections, defaultConnection, activationCandidate, activeSession, persistedComposerDefaults, openSettingsSection, openModelPicker } = options; + const { + uiLocale, + connections, + defaultConnection, + activationCandidate, + activeSession, + sessionHealthSession, + persistedComposerDefaults, + openSettingsSection, + openModelPicker, + } = options; const conversationCopy = getDesktopConversationCopy(uiLocale); const [pendingNewChatModelChoice, setPendingNewChatModel] = useNewTaskChoice< NewChatModelCandidate | null @@ -118,6 +129,13 @@ export function useShellChatModel(options: { connection.slug === activeSession.llmConnectionSlug, ) : undefined; + const sessionHealthConnection = sessionHealthSession + ? connections.find( + (connection) => + connection.connectionId === sessionHealthSession.llmConnectionId && + 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 @@ -231,13 +249,13 @@ export function useShellChatModel(options: { const sessionHealthNotice = useMemo(() => { const derived = deriveSessionHealthNotice({ locale: uiLocale, - session: activeSession, + session: sessionHealthSession, outcome: options.sessionSendOutcome, connections, hasModelChoices: chatModelChoices.length > 0, modelChoicesSettled: options.connectionSnapshotReady, modelPickerDisabled: options.modelPickerDisabled, - lastTestStatus: activeConnection?.lastTestStatus, + lastTestStatus: sessionHealthConnection?.lastTestStatus, }); if (!derived) return undefined; const target = derived.onClickTarget; @@ -259,16 +277,17 @@ 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?.llmConnectionId, + sessionHealthSession?.llmConnectionSlug, + sessionHealthSession?.model, options.sessionSendOutcome, connections, chatModelChoices.length, options.connectionSnapshotReady, options.modelPickerDisabled, options.refreshModelChoices, - activeConnection?.lastTestStatus, + sessionHealthConnection?.lastTestStatus, uiLocale, openModelPicker, ]); diff --git a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx index e5f7fae7d4..8da1b8a48b 100644 --- a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx +++ b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx @@ -123,7 +123,7 @@ test('the recovery handle opens the existing exact account-and-model picker', as assert.match(document.documentElement.innerHTML, /aria-expanded="true"[^>]*aria-haspopup="menu"/); assert.match(document.documentElement.innerHTML, /GPT-5/); - const items = [...document.querySelectorAll('[role="menuitem"]')]; + const items = [...document.querySelectorAll('[role="menuitemradio"]')]; assert.equal(items.length, 1, 'the stale legacy target is not a selectable current row'); await act(() => items[0]?.dispatchEvent(new window.Event('click', { bubbles: true }))); @@ -134,6 +134,37 @@ test('the recovery handle opens the existing exact account-and-model picker', as model: 'openai/gpt-5', }); assert.match(document.documentElement.innerHTML, /aria-expanded="false"[^>]*aria-haspopup="menu"/); + + await act(() => root.render( + + undefined} + onSend={() => undefined} + onStop={() => undefined} + /> + , + )); + await act(() => composer.current?.openModelPicker()); + + const selectedRadio = document.querySelector( + '[role="menuitemradio"][aria-checked="true"]', + ); + assert.equal(selectedRadio?.textContent?.includes('GPT-5'), true); + assert.match( + document.querySelector('.maka-model-switcher-trigger')?.getAttribute('aria-label') ?? '', + /GPT-5/, + ); } finally { await act(() => root.unmount()); Object.assign(globalThis, original); diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx index 2cc76a5635..6a6918ce72 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, @@ -46,7 +50,6 @@ import { exactModelChoiceValue, modelChoiceDescription, modelMenuGroups, - modelChoiceValue, } from './chat-model-helpers.js'; import { type ProviderType } from '@maka/core/llm-connections'; import { type SessionSummary } from '@maka/core/session'; @@ -74,15 +77,16 @@ const currentCheck =