diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index 322ed8d8fc..3d7d3b6ab3 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -171,9 +171,9 @@ test('data-backed conversation exposes ordered todos and keyboard access to tool const modelSwitcher = page.getByRole('button', { name: '切换当前任务模型' }); await tabTo(page, modelSwitcher, 'model picker'); await page.keyboard.press('Enter'); - await expect(page.getByRole('menuitem', { name: /glm-5\.1/ })).toBeVisible(); + await expect(page.getByRole('menuitemradio', { name: /glm-5\.1/ })).toBeVisible(); await assertAxHealth(cdp, 'conversation/model-picker'); - const availableModel = page.getByRole('menuitem', { name: 'glm-4.5', exact: true }); + const availableModel = page.getByRole('menuitemradio', { name: 'glm-4.5', exact: true }); await expect(availableModel).toBeFocused(); await page.keyboard.press('Enter'); await expect(modelSwitcher).toContainText('glm-4.5'); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index fc1b4e558b..02c3e00503 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", @@ -234,7 +233,6 @@ "src/renderer/use-new-task-choice.ts", "src/renderer/use-onboarding-snapshot.ts", "src/renderer/use-project-context.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", @@ -710,34 +708,6 @@ "importSpecifiers": 21, "nonTriviaTokens": 2974 }, - "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": 1264 - }, "src/renderer/app-shell-session-start-actions.ts": { "importDeclarations": 7, "bridgePaths": { @@ -784,7 +754,7 @@ "react": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 1223 + "nonTriviaTokens": 1183 }, "src/renderer/app-shell-stop-action.ts": { "importDeclarations": 4, @@ -859,7 +829,7 @@ "nonTriviaTokens": 1425 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 107, + "importDeclarations": 105, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -884,7 +854,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, @@ -928,7 +897,7 @@ "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, - "useSessionSettingIntent": 2, + "useSessionSettingIntent": 1, "useSettingsModal": 1, "useShellAppearance": 1, "useShellChatModel": 1, @@ -938,7 +907,7 @@ "useShellResume": 1, "useShellRunUpdates": 1, "useShellSearch": 1, - "useStableActions": 7, + "useStableActions": 6, "useState": 17, "useSystemUiLocale": 1, "useTaskEntryController": 1, @@ -965,7 +934,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, @@ -986,6 +954,7 @@ "./features/module-hub": 1, "./features/session-collaboration": 1, "./features/session-navigation": 1, + "./features/session-settings": 1, "./features/task-entry": 1, "./features/workbar": 1, "./follow-up-submit-routing": 1, @@ -1016,11 +985,9 @@ "./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-setting-intent": 1, "./use-settings-modal": 1, "./use-shell-appearance": 1, "./use-shell-chat-model": 1, @@ -1057,7 +1024,7 @@ "react": 1 }, "importSpecifiers": 186, - "nonTriviaTokens": 15800 + "nonTriviaTokens": 15779 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, @@ -1112,7 +1079,7 @@ "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { - "useExternalStoreSelector": 9 + "useExternalStoreSelector": 7 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -1124,7 +1091,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 391 + "nonTriviaTokens": 329 }, "src/renderer/use-app-shell-session-workspace.ts": { "importDeclarations": 11, @@ -2046,7 +2013,6 @@ "actionFactories": [], "dependencyPaths": { "@maka/core/goal": 1, - "@maka/core/model-thinking": 1, "@maka/core/permission": 1, "@maka/core/redaction": 1, "@maka/core/settings": 1, @@ -4926,21 +4892,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": { @@ -5597,7 +5548,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 fd7fc82fbd..0000000000 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ /dev/null @@ -1,379 +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 { deferred } from '@maka/core/test-only/async-primitives'; -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import type { ProjectedLlmConnection } 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 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?: ProjectedLlmConnection[]; - 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', catalogEntries: [] }] as unknown as ProjectedLlmConnection[]), - 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', catalogEntries: [] }, - { slug: 'relay', name: 'Relay', catalogEntries: [] }, - ] as unknown as ProjectedLlmConnection[], - }); - - 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__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 5287dca186..ca798394e8 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -25,6 +25,7 @@ import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { SessionEvent } from '@maka/core/events'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; +import type { PermissionMode } from '@maka/core/permission'; import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; import { createFakeWorkbarServices, @@ -53,6 +54,37 @@ type SideChatStopTarget = Parameters[1]; type QueueUpdate = Extract; type QueueEntry = NonNullable[number]; +test('declining Full access through the side-chat hook does not persist the permission mode', async () => { + let confirmations = 0; + let writes = 0; + let setPermissionMode!: (mode: PermissionMode) => Promise; + + const { container } = await renderProbe( + { + setPermissionMode: async (sessionId, mode) => { + writes += 1; + return session(sessionId, { permissionMode: mode }); + }, + }, + { + confirmBypass: async () => { + confirmations += 1; + return false; + }, + onSetPermissionMode: (setter) => { + setPermissionMode = setter; + }, + }, + ); + + assert.ok(container.firstElementChild); + const result = await act(async () => setPermissionMode('bypass')); + + assert.equal(result, false); + assert.equal(confirmations, 1); + assert.equal(writes, 0); +}); + function completeEvent(id: string, turnId: string, ts: number): SessionEvent { return { type: 'complete', id, turnId, ts, stopReason: 'end_turn' }; } @@ -129,6 +161,8 @@ async function renderProbe( onSend?: (send: (text: string) => Promise) => void; onSteer?: (steer: (text: string) => Promise) => void; onStop?: (stop: () => Promise) => void; + onSetPermissionMode?: (setPermissionMode: (mode: PermissionMode) => Promise) => void; + confirmBypass?: () => Promise; pendingQuotes?: readonly StagedCompanionQuote[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; } = {}, @@ -159,6 +193,8 @@ async function renderProbe( : createElement(QuoteCompanionProbe, { sourceSession: options.sourceSession, modelChoices: options.modelChoices, + onSetPermissionMode: options.onSetPermissionMode, + confirmBypass: options.confirmBypass, }); await act(async () => { @@ -1501,6 +1537,8 @@ test('releases a send waiting for observation when the Side Conversation is disp function QuoteCompanionProbe(props: { sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; + onSetPermissionMode?: (setPermissionMode: (mode: PermissionMode) => Promise) => void; + confirmBypass?: () => Promise; }) { const sourceSession = props.sourceSession ?? SOURCE_SESSION; const companion = useQuoteCompanion({ @@ -1510,7 +1548,9 @@ function QuoteCompanionProbe(props: { modelChoices: props.modelChoices ?? [choiceFor(sourceSession)], locale: 'en', onQuotesConsumed: () => undefined, + confirmBypass: props.confirmBypass ?? (async () => true), }); + props.onSetPermissionMode?.(companion.setPermissionMode); return createElement('div', { 'data-error': companion.error ?? '', 'data-companion-id': companion.companionSession?.id ?? '', @@ -1535,6 +1575,7 @@ function QuoteCompanionOwnershipProbe(props: { modelChoices: props.modelChoices ?? [choiceFor(sourceSession)], locale: 'en', onQuotesConsumed: props.onQuotesConsumed ?? (() => undefined), + confirmBypass: async () => true, }); props.onSend(companion.send); props.onSteer?.(companion.steer); 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 f159a811b9..0c60d47139 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 @@ -115,6 +115,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 f4c2586e5e..fc9ab1d6c5 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'; @@ -206,10 +206,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)}`); @@ -372,6 +376,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 da3dc9b7fc..33949c85e0 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1249,7 +1249,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 c462b5215b..0876bccc7b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2279,8 +2279,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 2041b4abc0..0000000000 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ /dev/null @@ -1,241 +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 { ProjectedLlmConnection } 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 ProjectedLlmConnection[]; - 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; - - // The Host's entries, not the stored rows: a provider with no model-list - // endpoint stores bare ids, and the pickers beside this show resolved names. - function modelLabel(connectionSlug: string, model: string): string { - const connection = connections.find((entry) => entry.slug === connectionSlug); - return connection?.catalogEntries.find((entry) => entry.id === model)?.displayName?.trim() || 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 270e1d6b39..9c30c227a2 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -144,13 +144,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'; @@ -190,9 +195,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); @@ -829,6 +830,40 @@ function AppShellContent({ ); const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId); 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; @@ -875,13 +910,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, @@ -910,7 +942,8 @@ function AppShellContent({ activationCandidate: modelSettingsOwnsComposerHost ? onboardingActivationCandidate : undefined, - activeSession, + activeSession: activeSessionForModelControls, + sessionHealthSession: activeSession, persistedComposerDefaults, usePersistedComposerDefaults: modelSettingsOwnsComposerHost, defaultThinkingLevel: taskEntry.selectors.selectedHost?.chatDefaults.thinkingLevel, @@ -947,62 +980,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). /** @@ -1064,7 +1044,7 @@ function AppShellContent({ return Promise.resolve(true); } if (active === activePlanMode) return Promise.resolve(true); - return planModeIntent.request(sessionId, active); + return sessionSettingIntent.setPlanMode(sessionId, active); } /** @@ -1081,7 +1061,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( @@ -1200,11 +1180,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; @@ -1268,7 +1248,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, @@ -3033,6 +3016,8 @@ function AppShellContent({ } modelLabel={activeModelLabel ?? newChatModelLabel ?? undefined} activeSession={activeSessionForView} + activeModelConnectionId={activeSessionForModelControls?.llmConnectionId} + activeModelConnectionSlug={activeSessionForModelControls?.llmConnectionSlug} activeModel={activeModel} activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} @@ -3041,10 +3026,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) => { @@ -3067,7 +3056,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 @@ -3075,15 +3063,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 @@ -3140,8 +3126,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 1e968c0880..91755a2677 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -24,6 +24,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'; @@ -32,6 +33,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'; @@ -43,6 +45,7 @@ export function createDesktopFeatureServices() { runtimeHostManagement: createDesktopRuntimeHostManagementServices(), sessionCollaboration: createDesktopSessionCollaborationServices(), sessionNavigation: createDesktopSessionNavigationServices(), + sessionSettings: createDesktopSessionSettingsServices(), taskEntry: createDesktopTaskEntryServices(), workbar: createDesktopWorkbarServices(), }; @@ -57,15 +60,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/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 8bf5c6bb0a..d00ff698ce 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -37,7 +37,9 @@ export * from './tools/inspector/session-inspector-overview-model.js'; export * from './tools/side-chat/quote-companion-panel-state.js'; export * from './tools/side-chat/quote-companion-core.js'; export * from './tools/side-chat/quote-companion-visibility.js'; -export { useQuoteCompanion } from './tools/side-chat/use-quote-companion.js'; +export { + useQuoteCompanion, +} from './tools/side-chat/use-quote-companion.js'; export * from './tools/terminal/session-terminal-hydration.js'; export * from './tools/terminal/session-terminal-query.js'; export * from './tools/terminal/session-terminal-frame.js'; 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 8f3f0dfbb3..616fb2675c 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 @@ -68,6 +68,7 @@ export function QuoteCompanionPanel(props: { sourceSession: SessionSummary | undefined; /** Shared global choice list, only used to render the inherited model's label. */ modelChoices: readonly ChatModelChoice[]; + confirmBypass: () => Promise; onQuotesConsumed: (snapshot: CompanionQuoteSnapshot) => void; onRemoveQuote?: (target: CompanionQuoteTarget) => void; onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void; @@ -103,6 +104,7 @@ export function QuoteCompanionPanel(props: { modelChoices: props.modelChoices, locale, onQuotesConsumed: props.onQuotesConsumed, + confirmBypass: props.confirmBypass, onForkVisibilityChange: props.onForkVisibilityChange, }); useEffect(() => { @@ -301,7 +303,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 e65526dfb6..b821eec099 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'; @@ -65,6 +66,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[]; @@ -111,11 +114,22 @@ export interface UseQuoteCompanionInput { locale: UiLocale; /** Called once a send has consumed the staged quotes, so the host clears them. */ onQuotesConsumed: (snapshot: CompanionQuoteSnapshot) => void; + /** Confirms the destructive Full access permission mode before it is written. */ + confirmBypass: () => Promise; /** Reports creation and authoritative cleanup so the host can keep every * ephemeral fork hidden for its complete lifetime. */ onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void; } +export async function requestPermissionModeWithConfirmation( + mode: PermissionMode, + confirmBypass: () => Promise, + write: () => Promise, +): Promise { + if (mode === 'bypass' && !(await confirmBypass())) return false; + return write(); +} + export interface UseQuoteCompanionResult { companionSession: SessionSummary | undefined; /** True after this temporary conversation has accepted at least one turn. */ @@ -131,7 +145,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; @@ -197,6 +210,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan sourceSessionRef.current = sourceSession; const modelChoicesRef = useRef(modelChoices); modelChoicesRef.current = modelChoices; + const confirmBypassRef = useRef(input.confirmBypass); + confirmBypassRef.current = input.confirmBypass; const sourceModelReady = sessionHasExactModelChoice(sourceSession, modelChoices); const sourceSessionId = sourceSession?.id; const sourceSessionIdRef = useRef(sourceSession?.id); @@ -230,7 +245,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, ); @@ -248,6 +262,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; @@ -529,6 +578,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?.({ @@ -612,7 +662,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan forkSetupPromiseRef.current = promise; return promise; }, - [commitFork, mountedRef, panelId, sideChat], + [clearPermissionModeIntent, commitFork, mountedRef, panelId, sideChat], ); const companionModelReady = @@ -950,25 +1000,17 @@ 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 requestPermissionModeWithConfirmation( + mode, + () => confirmBypassRef.current(), + () => requestPermissionMode(id, mode), + ); }, - [mountedRef, permissionModePending, sideChat, turnInFlight], + [requestPermissionMode, turnInFlight], ); const regenerate = useCallback( @@ -1053,7 +1095,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) @@ -1075,7 +1120,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan preparing, modelReady: sourceModelReady && companionModelReady, permissionMode, - permissionModePending, regeneratePendingTurnId, error, activeModel, diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx index b30e062b8a..95d8a023ee 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx @@ -21,10 +21,10 @@ import { lazy, Suspense, type ComponentProps, type CSSProperties } from 'react'; import { Card } from '@astryxdesign/core/Card'; import { ResizeHandle, type ResizableProps } from '@astryxdesign/core/Resizable'; import { Spinner } from '@astryxdesign/core/Spinner'; -import { Composer, useUiLocale } from '@maka/ui'; +import { Composer, useToast, useUiLocale } from '@maka/ui'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { SessionSummary } from '@maka/core/session'; -import { getShellCopy } from '../../../locales/shell-copy'; +import { confirmBypassPermission, getShellCopy } from '../../../locales/shell-copy'; import type { SessionWorkbarPanelsState, SessionWorkbarPlacement, @@ -137,7 +137,9 @@ export interface WorkbarHostModel { } export function WorkbarHost({ model: props }: { model: WorkbarHostModel }) { - const copy = getShellCopy(useUiLocale()).app; + const locale = useUiLocale(); + const toast = useToast(); + const copy = getShellCopy(locale).app; const style = { '--maka-session-workbar-width': `${props.rightWidth}px`, '--maka-session-bottom-panel-height': `${props.bottomHeight}px`, @@ -210,6 +212,7 @@ export function WorkbarHost({ model: props }: { model: WorkbarHostModel }) { activeSideChatPanelIds={props.activeSideChatPanelIds} sourceSession={props.sourceSession} modelChoices={props.modelChoices} + confirmBypass={() => confirmBypassPermission(toast, locale)} /> diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx index fc5ed6576a..1ee6256bc0 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx @@ -717,6 +717,7 @@ export function WorkbarSurface(props: { activeSideChatPanelIds?: ReadonlySet; sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; + confirmBypass: () => Promise; }) { const locale = useUiLocale(); const copy = getDesktopConversationCopy(locale).workbar; @@ -879,6 +880,7 @@ export function WorkbarSurface(props: { initialPrompt={quote.initialPrompt} sourceSession={props.sourceSession} modelChoices={props.modelChoices ?? []} + confirmBypass={props.confirmBypass} onQuotesConsumed={props.onQuotesConsumed ?? (() => {})} onRemoveQuote={props.onRemoveQuote} onForkVisibilityChange={props.onForkVisibilityChange} diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 0421063fe9..4de4e1a9c5 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 = [ @@ -352,23 +351,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; }; @@ -514,7 +504,6 @@ type ShellCopy = { boundaryUnreadableDetail: string; boundaryUnreadableRetry: string; boundaryUnreadableRetrying: string; - permissionModeChanging: string; permissionModeStreaming: string; permissionModeRunning: string; permissionModeWaiting: string; @@ -1033,39 +1022,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: '思考级别暂时无法切换,请稍后重试。', }, @@ -1288,7 +1253,6 @@ const SHELL_COPY_BY_LOCALE = { boundaryUnreadableDetail: '在读到之前,这里暂时不能输入。可以重试,或先切换到别的任务。', boundaryUnreadableRetry: '重试', boundaryUnreadableRetrying: '重试中…', - permissionModeChanging: '权限模式正在切换,完成后再继续操作。', permissionModeStreaming: '当前任务正在流式输出,等结束后再切换权限模式。', permissionModeRunning: '当前任务正在运行,等结束后再切换权限模式。', permissionModeWaiting: '当前有工具调用正在等待确认,处理后再切换权限模式。', @@ -1578,39 +1542,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.', }, @@ -1874,7 +1814,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.', @@ -1917,3 +1856,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 f1e2c2002c..5b9cc00b83 100644 --- a/apps/desktop/src/renderer/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/use-shell-chat-model.ts @@ -75,6 +75,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". */ @@ -103,7 +104,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 @@ -123,6 +134,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 @@ -247,13 +265,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; @@ -275,16 +293,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/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx b/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx index 84baefd6f6..3bd8066b29 100644 --- a/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx +++ b/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx @@ -101,6 +101,7 @@ function WorkbarToolSurface(props: { kind: 'terminal' | 'browser' | 'files' }) { onPinTab={noop} onOpenLauncher={noop} onRequestOpenTab={noop} + confirmBypass={async () => true} /> diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 9c5246717b..2d9eb2e3bf 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -994,6 +994,7 @@ function Workbar(props: { onPinTab={noop} onOpenLauncher={noop} onRequestOpenTab={noop} + confirmBypass={async () => true} quotes={quotes} sourceSession={ props.sourceSession ?? diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 2b832925c7..0ee02f368e 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 240 files — blocker 0, reimplementation 0, polish 1, aligned 239. +**Totals:** 241 files — blocker 0, reimplementation 0, polish 1, aligned 240. ## Exclusions (explicit) @@ -60,6 +60,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-badge.tsx` | shell-chrome-or-panel | Badge | aligned — uses Astryx (Badge) | aligned | | `apps/desktop/src/renderer/features/session-navigation/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/session-settings/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | @@ -210,7 +211,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/bot-brand-logo.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/capability-audit-strip.tsx` | ui-composition | Banner | aligned — uses Astryx (Banner) | aligned | | `packages/ui/src/chat-empty-hero.tsx` | shell-chrome-or-panel | Item | aligned — uses Astryx (Item) | aligned | -| `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuItem | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem) | aligned | +| `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem) | aligned | | `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned | | `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned | | `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 398cd565ec..497178016c 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -31,6 +31,7 @@ apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-badge.tsx apps/desktop/src/renderer/features/session-navigation/services-context.tsx apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +apps/desktop/src/renderer/features/session-settings/services-context.tsx apps/desktop/src/renderer/features/task-entry/services-context.tsx apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx apps/desktop/src/renderer/features/workbar/services-context.tsx 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 =