diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 10b064bcf4..f4dcfa6efb 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -284,6 +284,13 @@ "ownerSymbol": "OverlaysRoot", "count": 1 }, + { + "implementation": "src/renderer/features/session-settings/controller/use-session-settings-controller.ts", + "symbol": "useSessionSettingsController", + "owner": "src/renderer/features/session-settings/ui/session-settings-provider.tsx", + "ownerSymbol": "SessionSettingsProvider", + "count": 1 + }, { "implementation": "src/renderer/features/task-entry/controller/use-task-entry-controller.ts", "symbol": "useTaskEntryController", @@ -714,7 +721,6 @@ "window.maka.notifications.runEnded": 1, "window.maka.onboarding.setMilestone": 1, "window.maka.sessions.compact": 1, - "window.maka.sessions.getPlanState": 1, "window.maka.sessions.listActiveInteractions": 1, "window.maka.sessions.listTurnLandmarks": 1, "window.maka.sessions.promoteQueueEntry": 1, @@ -854,7 +860,7 @@ "react": 1 }, "importSpecifiers": 99, - "nonTriviaTokens": 12972 + "nonTriviaTokens": 12863 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, diff --git a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts index 7768dfc81e..58a94dd9db 100644 --- a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts @@ -25,8 +25,8 @@ import { parseHTML } from 'linkedom'; import { SessionSettingsServicesProvider, type SessionSettingsServices, - useSessionSettingIntent, } from '../../renderer/features/session-settings/index.js'; +import { useSessionSettingsController } from '../../renderer/features/session-settings/testing.js'; import { reconcileRuntimeHostSessionCatalog } from '../../preload/runtime-host-session-catalog.js'; import { createSessionCatalogController, @@ -34,7 +34,7 @@ import { } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; -type Controller = ReturnType>; +type Controller = ReturnType>; const originalGlobals = { document: globalThis.document, @@ -479,7 +479,7 @@ function Harness(props: { model: string; }): void; }) { - const controller = useSessionSettingIntent({ + const controller = useSessionSettingsController({ catalog: props.catalog, isActiveSession: () => true, newSessionPermissionMode: 'ask', @@ -487,7 +487,7 @@ function Harness(props: { saveComposerDefaults: props.saveComposerDefaults, writeFailureCopy: () => ({ title: 'failed', description: 'failed' }), showSessionError: () => {}, - planMode: { write: async () => true }, + planMode: { reportExecutionActive: () => {}, confirmDiscard: async () => true }, captureOwner: () => props.owner, isOwnerActive: () => true, setNewTaskPermissionMode: props.setNewTaskPermissionMode, @@ -501,7 +501,7 @@ function CausalRetirementHarness(props: { capture(controller: Controller): void; catalog: SessionCatalogController; }) { - const controller = useSessionSettingIntent({ + const controller = useSessionSettingsController({ catalog: props.catalog, isActiveSession: () => true, newSessionPermissionMode: 'ask', @@ -509,7 +509,7 @@ function CausalRetirementHarness(props: { saveComposerDefaults: () => {}, writeFailureCopy: () => ({ title: 'failed', description: 'failed' }), showSessionError: () => {}, - planMode: { write: async () => true }, + planMode: { reportExecutionActive: () => {}, confirmDiscard: async () => true }, captureOwner: () => ({ sessionId: 'session-a' }), isOwnerActive: () => true, setNewTaskPermissionMode: () => {}, @@ -545,6 +545,7 @@ function createServices( overrides: Partial = {}, ): SessionSettingsServices { return { + getPlanState: async (sessionId) => ({ schemaVersion: 1, sessionId, storeVersion: 0, proposals: [], executions: [] }), setModelConfiguration: async () => ({} as DesktopSessionSummary), setPermissionMode: async () => ({} as DesktopSessionSummary), setOrchestrationMode: async () => ({} as DesktopSessionSummary), diff --git a/apps/desktop/src/main/__tests__/session-settings-plan-mode.test.ts b/apps/desktop/src/main/__tests__/session-settings-plan-mode.test.ts new file mode 100644 index 0000000000..2034da17cf --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-settings-plan-mode.test.ts @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { PlanSessionState } from '@maka/core/plan'; +import type { SessionSettingsServices } from '../../renderer/features/session-settings/index.js'; +import { writeSessionPlanMode } from '../../renderer/features/session-settings/testing.js'; + +function fixture(state: Partial = {}, confirmed = true) { + const writes: unknown[] = []; + const errors: string[] = []; + const confirmations: string[] = []; + const services = { + getPlanState: async (sessionId) => ({ schemaVersion: 1, sessionId, storeVersion: 0, proposals: [], executions: [], ...state }), + abandonPlanProposal: async (...args) => { writes.push(['abandon', ...args]); }, + setCollaborationMode: async (...args) => { writes.push(['mode', ...args]); return {} as never; }, + } satisfies Pick; + const presentation = { + reportExecutionActive: (id: string) => { errors.push(id); }, + confirmDiscard: async (title: string) => { confirmations.push(title); return confirmed; }, + }; + return { services, presentation, writes, errors, confirmations }; +} + +test('entering Plan is refused when the Host reports an active execution', async () => { + const f = fixture({ activeExecutionId: 'execution' }); + assert.equal(await writeSessionPlanMode(f.services, f.presentation, 'a', true), false); + assert.deepEqual(f.writes, []); + assert.deepEqual(f.errors, ['a']); +}); + +test('canceling the latest pending proposal confirmation makes no write', async () => { + const f = fixture({ latestProposalId: 'p', proposals: [{ proposalId: 'p', title: 'Keep this', status: 'pending_approval' } as never] }, false); + assert.equal(await writeSessionPlanMode(f.services, f.presentation, 'a', false), false); + assert.deepEqual(f.confirmations, ['Keep this']); + assert.deepEqual(f.writes, []); +}); + +test('ordinary Plan transitions write only collaboration mode, preserving orchestration', async () => { + const f = fixture(); + assert.equal(await writeSessionPlanMode(f.services, f.presentation, 'a', true), true); + assert.equal(await writeSessionPlanMode(f.services, f.presentation, 'a', false), true); + assert.deepEqual(f.writes, [['mode', 'a', 'plan'], ['mode', 'a', 'agent']]); + assert.deepEqual(f.confirmations, []); +}); + +test('Host read errors propagate to the intent error path without writing', async () => { + const f = fixture(); + f.services.getPlanState = async () => { throw new Error('offline'); }; + await assert.rejects(writeSessionPlanMode(f.services, f.presentation, 'a', true), /offline/); + assert.deepEqual(f.writes, []); +}); diff --git a/apps/desktop/src/main/__tests__/session-settings-provider-scope.test.ts b/apps/desktop/src/main/__tests__/session-settings-provider-scope.test.ts new file mode 100644 index 0000000000..d312742934 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-settings-provider-scope.test.ts @@ -0,0 +1,232 @@ +/* + * 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 { act, createElement, StrictMode } from 'react'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + SessionSettingsProvider, + SessionSettingsServicesProvider, + useSessionSettingIntent, + type SessionSettingsServices, +} from '../../renderer/features/session-settings/index.js'; +import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; +import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; + +const model = { llmConnectionId: 'connection', llmConnectionSlug: 'openai', model: 'next' }; +const session = (id: string) => ({ id, revision: 1, permissionMode: 'ask', ...model } as DesktopSessionSummary); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function services(overrides: Partial = {}): SessionSettingsServices { + return { + getPlanState: async (sessionId) => ({ schemaVersion: 1, sessionId, storeVersion: 0, proposals: [], executions: [] }), + setModelConfiguration: async (id, input) => ({ ...session(id), ...input, thinkingLevel: input.thinkingLevel ?? undefined, revision: 2 }), + setPermissionMode: async (id, mode) => ({ ...session(id), permissionMode: mode, revision: 2 }), + setOrchestrationMode: async (id, mode) => ({ ...session(id), orchestrationMode: mode, revision: 2 }), + setCollaborationMode: async (id, mode) => ({ ...session(id), collaborationMode: mode, revision: 2 }), + abandonPlanProposal: async () => {}, + ...overrides, + }; +} + +afterEach(cleanupFakeDom); + +async function mount(options: { + services?: SessionSettingsServices; + confirmBypass?: () => Promise; + confirmDiscard?: (title: string) => Promise; + strict?: boolean; +} = {}) { + const { root } = installReactRenderer(); + const catalog = createSessionCatalogController(); + catalog.commitSessions([session('a'), session('b')]); + let selected: string | undefined = 'a'; + let owner: { sessionId?: string } = { sessionId: selected }; + let snapshot!: ReturnType; + let renders = 0; + let frameRenders = 0; + const errors: unknown[] = []; + const service = options.services ?? services(); + function Frame() { frameRenders += 1; return null; } + function Shell() { + renders += 1; + snapshot = useSessionSettingIntent(selected); + return createElement(SessionSettingsProvider, { + bridge: snapshot.bridge, + input: { + catalog, + isActiveSession: (id) => id === selected, + newSessionPermissionMode: 'ask', + refreshCatalog: async () => {}, + saveComposerDefaults: () => {}, + writeFailureCopy: () => ({ title: 'failed', description: 'failed' }), + showSessionError: (...args) => { errors.push(args); }, + planMode: { + reportExecutionActive: (id) => { errors.push(['execution-active', id]); }, + confirmDiscard: options.confirmDiscard ?? (async () => true), + }, + captureOwner: () => owner, + isOwnerActive: (claim) => claim === owner, + setNewTaskPermissionMode: () => {}, + confirmBypass: options.confirmBypass ?? (async () => true), + }, + }, createElement(Frame)); + } + const render = () => root.render(createElement( + SessionSettingsServicesProvider, { services: service }, + options.strict ? createElement(StrictMode, null, createElement(Shell)) : createElement(Shell), + )); + await act(render); + return { + root, errors, catalog, + current: () => snapshot, + renders: () => renders, + frameRenders: () => frameRenders, + select: async (id: string | undefined) => { + selected = id; + owner = { sessionId: selected }; + await act(render); + }, + }; +} + +test('inactive Session writes keep the shell and frame asleep; selection reads the right overlay immediately', async () => { + const write = deferred(); + const h = await mount({ services: services({ setModelConfiguration: () => write.promise }) }); + const commands = h.current().commands; + const initialRenders = h.renders(); + const initialFrames = h.frameRenders(); + let completion!: Promise; + await act(() => { completion = commands.setSessionModel('b', model); }); + assert.equal(h.renders(), initialRenders); + assert.equal(h.frameRenders(), initialFrames); + assert.equal(h.current().overlay.modelConfiguration, undefined); + await act(async () => { write.resolve({ ...session('b'), thinkingLevel: undefined, revision: 2 }); await completion; }); + assert.equal(h.renders(), initialRenders); + assert.equal(h.frameRenders(), initialFrames); + await h.select('b'); + assert.equal(h.current().overlay.modelConfiguration?.modelTarget.model, 'next'); + assert.equal(h.current().commands, commands); + await h.select('a'); + assert.equal(h.current().overlay.modelConfiguration, undefined); + await act(() => h.root.unmount()); +}); + +test('catalog observations retire only the acknowledged Session overlay without waking the shell', async () => { + const h = await mount(); + const commands = h.current().commands; + const initialRenders = h.renders(); + const initialFrames = h.frameRenders(); + await act(async () => { assert.equal(await commands.setSessionModel('b', model), true); }); + const overlay = () => h.current().bridge.getState().modelConfiguration.b; + assert.equal(overlay()?.modelTarget.model, 'next'); + + await act(() => h.catalog.commitSessions([{ ...session('a'), revision: 2 }, session('b')])); + assert.equal(overlay()?.modelTarget.model, 'next'); + await act(() => h.catalog.commitSessions([{ ...session('a'), revision: 2 }, { ...session('b'), revision: 2 }])); + assert.equal(overlay(), undefined); + assert.equal(h.renders(), initialRenders); + assert.equal(h.frameRenders(), initialFrames); + assert.equal(h.current().commands, commands); + await h.select('b'); + assert.equal(h.current().overlay.modelConfiguration, undefined); + await act(() => h.root.unmount()); +}); + +test('active optimistic state rolls back on failure through the same stable command port', async () => { + const write = deferred(); + const h = await mount({ services: services({ setModelConfiguration: () => write.promise }) }); + const commands = h.current().commands; + let completion!: Promise; + await act(() => { completion = commands.setSessionModel('a', model); }); + assert.equal(h.current().overlay.modelConfiguration?.modelTarget.model, 'next'); + let result = true; + await act(async () => { write.reject(new Error('Host unavailable')); result = await completion; }); + assert.equal(result, false); + assert.equal(h.current().overlay.modelConfiguration, undefined); + assert.equal(h.current().commands, commands); + assert.deepEqual(h.errors, [['a', 'failed', 'failed']]); + await act(() => h.root.unmount()); +}); + +test('a bypass confirmation cannot write after its captured selection owner changes', async () => { + const confirmation = deferred(); + const writes: unknown[] = []; + const h = await mount({ + confirmBypass: () => confirmation.promise, + services: services({ setPermissionMode: async (...args) => { writes.push(args); return session(args[0]); } }), + }); + let completion!: Promise; + await act(() => { completion = h.current().commands.setPermissionMode('bypass'); }); + await h.select('b'); + let result = true; + await act(async () => { confirmation.resolve(true); result = await completion; }); + assert.equal(result, false); + assert.deepEqual(writes, []); + await act(() => h.root.unmount()); +}); + +test('clear retires an in-flight intent, and StrictMode cleanup disconnects retained commands', async () => { + const write = deferred(); + let writes = 0; + const h = await mount({ strict: true, services: services({ setModelConfiguration: () => { writes += 1; return write.promise; } }) }); + const commands = h.current().commands; + let completion!: Promise; + await act(() => { completion = commands.setSessionModel('a', model); }); + assert.equal(writes, 1); + await act(() => commands.clear('a')); + assert.equal(await completion, false); + assert.equal(h.current().overlay.modelConfiguration, undefined); + await act(() => h.root.unmount()); + assert.equal(await commands.setSessionModel('b', model), false); + await act(async () => write.resolve({ ...session('a'), thinkingLevel: undefined, revision: 2 })); + assert.equal(writes, 1); +}); + +test('Plan discard remains bound to the requested Session while the user switches away', async () => { + const confirmation = deferred(); + const writes: unknown[] = []; + const h = await mount({ + confirmDiscard: () => confirmation.promise, + services: services({ + getPlanState: async (sessionId) => ({ + schemaVersion: 1, sessionId, storeVersion: 1, executions: [], latestProposalId: 'proposal-a', + proposals: [{ proposalId: 'proposal-a', title: 'Original plan', status: 'pending_approval' } as never], + }), + abandonPlanProposal: async (...args) => { writes.push(args); }, + setCollaborationMode: async () => { assert.fail('abandon already leaves Plan'); }, + }), + }); + let completion!: Promise; + await act(() => { completion = h.current().commands.setPlanMode('a', false); }); + await h.select('b'); + let result = false; + await act(async () => { confirmation.resolve(true); result = await completion; }); + assert.equal(result, true); + assert.deepEqual(writes, [['a', 'proposal-a']]); + assert.equal(h.current().overlay.planMode, undefined); + await act(() => h.root.unmount()); +}); 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 index cf6353b7aa..d0d18c88c4 100644 --- a/apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts @@ -27,6 +27,7 @@ test('maps session setting services to the existing compound Desktop bridge', as const sessions = new Proxy({}, { get: (_target, property) => (...args: unknown[]) => { calls.push({ name: String(property), args }); + if (property === 'getPlanState') return Promise.resolve({ sessionId: args[0], proposals: [] }); if (property === 'abandonPlanProposal') return Promise.resolve({ ok: true, value: {} }); return Promise.resolve({ ok: true, session: {} }); }, @@ -41,6 +42,7 @@ test('maps session setting services to the existing compound Desktop bridge', as model: 'gpt-5', thinkingLevel: 'high', }); + assert.deepEqual(await services.getPlanState('session-1'), { sessionId: 'session-1', proposals: [] }); await services.setPermissionMode('session-1', 'bypass'); await services.setOrchestrationMode('session-1', 'swarm'); await services.abandonPlanProposal('session-1', 'proposal-1'); @@ -55,6 +57,7 @@ test('maps session setting services to the existing compound Desktop bridge', as thinkingLevel: 'high', }], }, + { name: 'getPlanState', args: ['session-1'] }, { name: 'setPermissionMode', args: ['session-1', 'bypass'] }, { name: 'setOrchestrationMode', args: ['session-1', 'swarm'] }, { name: 'abandonPlanProposal', args: ['session-1', 'proposal-1'] }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 2fee1445e1..60b3c58ecf 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -126,7 +126,7 @@ 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 { useSessionSettingIntent } from './features/session-settings'; +import { SessionSettingsProvider, useSessionSettingIntent } from './features/session-settings'; import { pendingSessionView } from './pending-session-view'; import { useAppShellTurnPresentation } from './app-shell-turn-view-model'; import { readScrollMotionBehavior } from './scroll-motion-policy'; @@ -649,26 +649,9 @@ function AppShellContent({ const rendererMountedRef = useRef(true); const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId); const activeSession = activeCatalogSession; - const sessionSettingIntent = useSessionSettingIntent({ - catalog: sessionCatalogController, - isActiveSession: (sessionId) => activeIdRef.current === sessionId, - newSessionPermissionMode, - 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 sessionSettingIntent = useSessionSettingIntent(activeId); + const { setPermissionMode, setSessionModel, setSessionThinkingLevel } = sessionSettingIntent.commands; + const modelConfigurationOverlay = sessionSettingIntent.overlay.modelConfiguration; const activeSessionForModelControls = activeSession ? { ...activeSession, @@ -790,58 +773,11 @@ function AppShellContent({ // session from every session-UI map — the four pending claims included. clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); - sessionSettingIntent.clear(sessionId); + sessionSettingIntent.commands.clear(sessionId); } // Stable: the rail's row actions are built from it, and it only reaches // registries and refs that are themselves stable (#4109). - /** - * Enter or leave Plan for one Session — the only path that writes - * `collaborationMode`, and it writes nothing else. - * - * `sessionId` is a parameter rather than a read of `activeIdRef`, because - * this awaits — a Plan-exit confirmation can sit open while the user opens - * another Session, and a re-read partway through would finish the - * transition somewhere else. - * - * Both gates read the Host through `getPlanState`, not the projected mode. - * The projection can be a frame behind; the question "does this discard a - * pending plan proposal" has an authoritative answer and deserves it. - * - * The Session's orchestration default is left exactly as it was. Plan is a - * temporary excursion that Runtime ends by itself once a proposal is - * approved or abandoned, so clearing the default on the way in would lose - * it for the execution the plan was written for. - */ - async function commitPlanMode(sessionId: string, active: boolean): Promise { - const planState = await window.maka.sessions.getPlanState(sessionId); - if (active && planState.activeExecutionId) { - showSessionError( - sessionId, - shellCopy.planModeExecutionActiveTitle, - shellCopy.planModeExecutionActiveDescription, - ); - return false; - } - const latestProposal = planState.proposals.find( - (proposal) => proposal.proposalId === planState.latestProposalId, - ); - if (!active && latestProposal?.status === 'pending_approval') { - const confirmed = await toastApi.confirm({ - title: shellCopy.planModeExitPendingTitle, - description: shellCopy.planModeExitPendingDescription(latestProposal.title), - confirmLabel: shellCopy.planModeExitConfirm, - cancelLabel: shellCopy.planModeExitCancel, - destructive: true, - }); - if (!confirmed) return false; - // Abandoning the proposal is what leaves Plan: Runtime writes the - // Session back to `agent` itself as part of it. - await sessionSettingIntent.abandonPlanProposal(sessionId, latestProposal.proposalId); - } else await sessionSettingIntent.setCollaborationMode(sessionId, active ? 'plan' : 'agent'); - return true; - } - function setPlanMode(active: boolean): Promise { const sessionId = activeIdRef.current; if (!sessionId) { @@ -849,7 +785,7 @@ function AppShellContent({ return Promise.resolve(true); } if (active === activePlanMode) return Promise.resolve(true); - return sessionSettingIntent.setPlanMode(sessionId, active); + return sessionSettingIntent.commands.setPlanMode(sessionId, active); } /** @@ -866,7 +802,7 @@ function AppShellContent({ return Promise.resolve(true); } if (mode === activeOrchestrationMode) return Promise.resolve(true); - return sessionSettingIntent.setOrchestrationMode(sessionId, mode); + return sessionSettingIntent.commands.setOrchestrationMode(sessionId, mode); } function setOrchestrationModeActive( @@ -936,11 +872,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 - ? sessionSettingIntent.overlays.planMode[activeId] + ? sessionSettingIntent.overlay.planMode ?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan') : newChatPlanModeActive; const activeOrchestrationMode: OrchestrationMode = activeId - ? sessionSettingIntent.overlays.orchestrationMode[activeId] + ? sessionSettingIntent.overlay.orchestrationMode ?? activeSessionForView?.orchestrationMode ?? 'default' : newChatOrchestrationMode; @@ -1002,7 +938,7 @@ function AppShellContent({ activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newSessionPermissionMode, ); const activePermissionMode = activeId - ? sessionSettingIntent.overlays.permissionMode[activeId] + ? sessionSettingIntent.overlay.permissionMode ?? activeBoundarySurface.permissionMode : activeBoundarySurface.permissionMode; const planMode = usePlanModeState(ownerActiveId ? activeHostSession : undefined); @@ -2161,6 +2097,34 @@ function AppShellContent({ // readers. Composer mentions still wrap the frame so one projection serves // every composer, including side-chat panels, without rebuilding the frame // on catalog moves. + activeIdRef.current === sessionId, + newSessionPermissionMode, + refreshCatalog: refreshSessions, + saveComposerDefaults: (model) => saveComposerDefaults({ model }), + writeFailureCopy: (setting, error) => sessionSettingFailureCopy(uiLocale, setting, error), + showSessionError, + planMode: { + reportExecutionActive: (sessionId) => showSessionError( + sessionId, shellCopy.planModeExecutionActiveTitle, shellCopy.planModeExecutionActiveDescription, + ), + confirmDiscard: (title) => toastApi.confirm({ + title: shellCopy.planModeExitPendingTitle, + description: shellCopy.planModeExitPendingDescription(title), + confirmLabel: shellCopy.planModeExitConfirm, + cancelLabel: shellCopy.planModeExitCancel, + destructive: true, + }), + }, + captureOwner: captureComposerImportOwner, + isOwnerActive: isComposerImportOwnerActive, + setNewTaskPermissionMode, + confirmBypass: () => confirmBypassPermission(toastApi, uiLocale), + }} + > + ); } diff --git a/apps/desktop/src/renderer/features/session-settings/README.md b/apps/desktop/src/renderer/features/session-settings/README.md new file mode 100644 index 0000000000..46cc9bd3ce --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/README.md @@ -0,0 +1,64 @@ + + +# Session settings + +`SessionSettingsProvider` is the sole production owner of +`useSessionSettingsController`. It owns each Session's model/thinking, +permission, Plan and orchestration write intents, including optimistic overlays, +latest-intent convergence and retirement after the catalog observes a write. +It subscribes to the Session catalog directly; catalog observations do not need +the shell to pass a new list or revision for overlays to retire. +Desktop operations enter through `SessionSettingsServices`; this feature never +reads `window.maka`. + +## Shell boundary + +`useSessionSettingIntent(sessionId)` retains the shell's existing hook name, +but is now an equality-selected read of only that Session's four overlays. +It creates a per-shell bridge, without calling the write controller. The shell +still needs these values to derive its model picker and mode controls. The hook +inventory therefore stays at one call; it does not claim that all settings +reads have left the shell. + +The bridge forwards stable commands to the provider's latest committed +controller. The provider publishes after commit and reuses the shell's children +on its own updates. Writes for other Sessions do not re-render the shell or its +frame. Cleanup disconnects commands and clears the published overlays. + +## Preserved behavior + +- Model and thinking remain one compound write. Only a successfully committed + model selection updates the Composer defaults. +- Model, permission and orchestration overlays retire against the owning + Session's committed revision, not an unrelated Runtime Host catalog update. +- Bypass confirmation retains its captured Composer owner and refuses to write + if that owner changed while the confirmation was open. +- Plan entry checks the Host's current execution. Leaving a pending proposal + requires confirmation and abandons that exact proposal on the requested + Session, even if navigation changes meanwhile. The Runtime leaves Plan as + part of abandoning; orchestration is preserved. +- Failed writes retain the existing rollback and active-Session error behavior. + +`index.ts` exports the owner and the shell read; `testing.ts` exposes the +controller and Plan policy to tests. `controllerOwners` enforces the production +ownership boundary. + +Plan panel subscriptions/presentation, new-task drafts, the Session catalog and +the Session Collaboration dialog remain owned by their existing boundaries. diff --git a/apps/desktop/src/renderer/features/session-settings/controller/session-settings-bridge.ts b/apps/desktop/src/renderer/features/session-settings/controller/session-settings-bridge.ts new file mode 100644 index 0000000000..c7adf5a0e5 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/controller/session-settings-bridge.ts @@ -0,0 +1,66 @@ +/* + * 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 { + SessionSettingsCommands, + SessionSettingsController, + SessionSettingsOverlays, +} from '../model/session-settings-contract.js'; + +const EMPTY_OVERLAYS: SessionSettingsOverlays = { + modelConfiguration: {}, permissionMode: {}, planMode: {}, orchestrationMode: {}, +}; + +/** Per-shell command port and read store; the provider alone owns write state. */ +export function createSessionSettingsBridge() { + let controller: SessionSettingsController | undefined; + let overlays = EMPTY_OVERLAYS; + const listeners = new Set<() => void>(); + const publishOverlays = (next: SessionSettingsOverlays) => { + if (overlays === next) return; + overlays = next; + for (const listener of [...listeners]) listener(); + }; + const commands: SessionSettingsCommands = { + clear: (id) => controller?.clear(id), + setSessionModel: (id, model) => controller?.setSessionModel(id, model) ?? Promise.resolve(false), + setSessionThinkingLevel: (id, level) => controller?.setSessionThinkingLevel(id, level) ?? Promise.resolve(false), + setPermissionMode: (mode) => controller?.setPermissionMode(mode) ?? Promise.resolve(false), + setPlanMode: (id, active) => controller?.setPlanMode(id, active) ?? Promise.resolve(false), + setOrchestrationMode: (id, mode) => controller?.setOrchestrationMode(id, mode) ?? Promise.resolve(false), + }; + return { + commands, + getState: () => overlays, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + publish(next: SessionSettingsController) { + controller = next; + publishOverlays(next.overlays); + }, + disconnect() { + controller = undefined; + publishOverlays(EMPTY_OVERLAYS); + }, + }; +} + +export type SessionSettingsBridge = ReturnType; diff --git a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts b/apps/desktop/src/renderer/features/session-settings/controller/use-session-settings-controller.ts similarity index 81% rename from apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts rename to apps/desktop/src/renderer/features/session-settings/controller/use-session-settings-controller.ts index e094c3570d..ec9a52e0cc 100644 --- a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts +++ b/apps/desktop/src/renderer/features/session-settings/controller/use-session-settings-controller.ts @@ -23,7 +23,6 @@ import type { PermissionMode } from '@maka/core/permission'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import { isChatDefaultPermissionMode, - type ChatDefaultPermissionMode, } from '@maka/core/settings'; import { useSessionSettingIntent as useSharedSessionSettingIntent, @@ -33,38 +32,20 @@ import { equalSessionModelConfigurationIntent, modelConfigurationIntentForModel, modelConfigurationIntentForThinking, - type SessionModelConfigurationIntent, type SessionModelTarget, -} from './session-model-configuration-intent.js'; -import type { SessionCatalogController } from '../../application/contracts/session-catalog/session-catalog-state.js'; -import { useSessionSettingsServices } from './services-context.js'; +} from '../session-model-configuration-intent.js'; +import { useSessionSettingsServices } from '../services-context.js'; -type SessionSettingValues = { - modelConfiguration: SessionModelConfigurationIntent; - permissionMode: ChatDefaultPermissionMode; - planMode: boolean; - orchestrationMode: OrchestrationMode; -}; +import type { + SessionSettingValues, + SessionSettingsController, + SessionSettingsInput, +} from '../model/session-settings-contract.js'; +import { writeSessionPlanMode } from '../model/write-session-plan-mode.js'; -export function useSessionSettingIntent(input: { - catalog: SessionCatalogController; - isActiveSession(sessionId: string): boolean; - newSessionPermissionMode: 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; -}) { +export function useSessionSettingsController( + input: SessionSettingsInput, +): SessionSettingsController { const services = useSessionSettingsServices(); const reportWriteError = ( sessionId: string, @@ -121,7 +102,7 @@ export function useSessionSettingIntent(in planMode: { // Exiting a pending proposal returns Plan state rather than a Session // summary, so this policy channel has no authoritative Session revision. - write: input.planMode.write, + write: (sessionId, active) => writeSessionPlanMode(services, input.planMode, sessionId, active), onWriteError: (sessionId, error) => reportWriteError(sessionId, error, 'plan'), }, orchestrationMode: { @@ -141,8 +122,6 @@ export function useSessionSettingIntent(in return { clear: intent.clear, - abandonPlanProposal: services.abandonPlanProposal, - setCollaborationMode: services.setCollaborationMode, overlays: intent.overlayByChannel, setSessionModel: (sessionId: string, modelTarget: SessionModelTarget) => intent.request('modelConfiguration', sessionId, modelConfigurationIntentForModel(modelTarget)), diff --git a/apps/desktop/src/renderer/features/session-settings/index.ts b/apps/desktop/src/renderer/features/session-settings/index.ts index 8c4cf84c55..820f7b5813 100644 --- a/apps/desktop/src/renderer/features/session-settings/index.ts +++ b/apps/desktop/src/renderer/features/session-settings/index.ts @@ -18,5 +18,6 @@ */ export { SessionSettingsServicesProvider } from './services-context.js'; -export { useSessionSettingIntent } from './use-session-setting-intent.js'; +export { useSessionSettingIntent } from './ui/use-session-setting-intent.js'; export type { SessionSettingsServices } from './ports.js'; +export { SessionSettingsProvider } from './ui/session-settings-provider.js'; diff --git a/apps/desktop/src/renderer/features/session-settings/model/session-settings-contract.ts b/apps/desktop/src/renderer/features/session-settings/model/session-settings-contract.ts new file mode 100644 index 0000000000..e46ec2dedd --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/model/session-settings-contract.ts @@ -0,0 +1,70 @@ +/* + * 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 { ThinkingLevel } from '@maka/core/model-thinking'; +import type { ChatDefaultPermissionMode } from '@maka/core/settings'; +import type { SessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import type { SessionModelConfigurationIntent, SessionModelTarget } from '../session-model-configuration-intent.js'; + +export interface SessionSettingValues { + modelConfiguration: SessionModelConfigurationIntent; + permissionMode: ChatDefaultPermissionMode; + planMode: boolean; + orchestrationMode: OrchestrationMode; +} + +export type SessionSettingsOverlays = { + readonly [Key in keyof SessionSettingValues]: Readonly>; +}; + +export interface SessionSettingsInput { + catalog: SessionCatalogController; + isActiveSession(sessionId: string): boolean; + newSessionPermissionMode: 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: { + reportExecutionActive(sessionId: string): void; + confirmDiscard(proposalTitle: string): Promise; + }; + captureOwner(): Owner; + isOwnerActive(owner: Owner): boolean; + setNewTaskPermissionMode(mode: ChatDefaultPermissionMode): void; + confirmBypass(): Promise; +} + +export interface SessionSettingsCommands { + clear(sessionId: string): void; + setSessionModel(sessionId: string, model: SessionModelTarget): Promise; + setSessionThinkingLevel(sessionId: string, level: ThinkingLevel | null): Promise; + setPermissionMode(mode: PermissionMode): Promise; + setPlanMode(sessionId: string, active: boolean): Promise; + setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise; +} + +export interface SessionSettingsController extends SessionSettingsCommands { + readonly overlays: SessionSettingsOverlays; +} diff --git a/apps/desktop/src/renderer/features/session-settings/model/write-session-plan-mode.ts b/apps/desktop/src/renderer/features/session-settings/model/write-session-plan-mode.ts new file mode 100644 index 0000000000..55a3712e8d --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/model/write-session-plan-mode.ts @@ -0,0 +1,44 @@ +/* + * 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 { SessionSettingsServices } from '../ports.js'; +import type { SessionSettingsInput } from './session-settings-contract.js'; + +/** Read the Host authority before changing Plan; never retarget after an await. */ +export async function writeSessionPlanMode( + services: Pick, + presentation: SessionSettingsInput<{ sessionId?: string }>['planMode'], + sessionId: string, + active: boolean, +): Promise { + const state = await services.getPlanState(sessionId); + if (active && state.activeExecutionId) { + presentation.reportExecutionActive(sessionId); + return false; + } + const proposal = state.proposals.find((item) => item.proposalId === state.latestProposalId); + if (!active && proposal?.status === 'pending_approval') { + if (!(await presentation.confirmDiscard(proposal.title))) return false; + // Abandoning the proposal also leaves Plan in the Runtime authority. + await services.abandonPlanProposal(sessionId, proposal.proposalId); + } else { + await services.setCollaborationMode(sessionId, active ? 'plan' : 'agent'); + } + return true; +} diff --git a/apps/desktop/src/renderer/features/session-settings/ports.ts b/apps/desktop/src/renderer/features/session-settings/ports.ts index 9e6a155777..19bbf6d840 100644 --- a/apps/desktop/src/renderer/features/session-settings/ports.ts +++ b/apps/desktop/src/renderer/features/session-settings/ports.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { PlanSessionState } from '@maka/core/plan'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; @@ -25,6 +26,7 @@ import type { DesktopSessionSummary } from '../../../shared/desktop-session-proj import type { SessionModelTarget } from './session-model-configuration-intent.js'; export interface SessionSettingsServices { + getPlanState(sessionId: string): Promise; setModelConfiguration( sessionId: string, input: SessionModelTarget & { thinkingLevel: ThinkingLevel | null }, diff --git a/apps/desktop/src/renderer/features/session-settings/testing.ts b/apps/desktop/src/renderer/features/session-settings/testing.ts index 3b070d8bee..94f28c51c1 100644 --- a/apps/desktop/src/renderer/features/session-settings/testing.ts +++ b/apps/desktop/src/renderer/features/session-settings/testing.ts @@ -22,3 +22,5 @@ export { modelConfigurationIntentForModel, modelConfigurationIntentForThinking, } from './session-model-configuration-intent.js'; +export { useSessionSettingsController } from './controller/use-session-settings-controller.js'; +export { writeSessionPlanMode } from './model/write-session-plan-mode.js'; diff --git a/apps/desktop/src/renderer/features/session-settings/ui/session-settings-provider.tsx b/apps/desktop/src/renderer/features/session-settings/ui/session-settings-provider.tsx new file mode 100644 index 0000000000..310c7e638a --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/ui/session-settings-provider.tsx @@ -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 { useLayoutEffect, type ReactNode } from 'react'; +import { useSessionSettingsController } from '../controller/use-session-settings-controller.js'; +import type { SessionSettingsBridge } from '../controller/session-settings-bridge.js'; +import type { SessionSettingsInput } from '../model/session-settings-contract.js'; + +/** Sole controller owner. Its updates reuse children built by the shell. */ +export function SessionSettingsProvider(props: { + readonly bridge: SessionSettingsBridge; + readonly input: SessionSettingsInput; + readonly children?: ReactNode; +}) { + const controller = useSessionSettingsController(props.input); + useLayoutEffect(() => props.bridge.publish(controller), [props.bridge, controller]); + useLayoutEffect(() => () => props.bridge.disconnect(), [props.bridge]); + return props.children; +} diff --git a/apps/desktop/src/renderer/features/session-settings/ui/use-session-setting-intent.ts b/apps/desktop/src/renderer/features/session-settings/ui/use-session-setting-intent.ts new file mode 100644 index 0000000000..6e2cade72c --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/ui/use-session-setting-intent.ts @@ -0,0 +1,66 @@ +/* + * 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 { useMemo, useSyncExternalStore } from 'react'; +import { createSessionSettingsBridge } from '../controller/session-settings-bridge.js'; +import type { SessionSettingValues, SessionSettingsOverlays } from '../model/session-settings-contract.js'; +import { equalSessionModelConfigurationIntent } from '../session-model-configuration-intent.js'; + +type Selection = Partial; + +function select(overlays: SessionSettingsOverlays, sessionId?: string): Selection { + return sessionId ? { + modelConfiguration: overlays.modelConfiguration[sessionId], + permissionMode: overlays.permissionMode[sessionId], + planMode: overlays.planMode[sessionId], + orchestrationMode: overlays.orchestrationMode[sessionId], + } : {}; +} + +function equal(left: Selection, right: Selection): boolean { + const a = left.modelConfiguration; + const b = right.modelConfiguration; + return (a === b || Boolean(a && b && equalSessionModelConfigurationIntent(a, b))) && + left.permissionMode === right.permissionMode && + left.planMode === right.planMode && + left.orchestrationMode === right.orchestrationMode; +} + +/** + * The shell's remaining intent read: only its selected Session's four overlays. + * No write controller is called here. Inactive Session writes do not wake it. + */ +export function useSessionSettingIntent(sessionId?: string) { + const bridge = useMemo(createSessionSettingsBridge, []); + const getSnapshot = useMemo(() => { + let state: SessionSettingsOverlays | undefined; + let selection: Selection = {}; + return () => { + const nextState = bridge.getState(); + if (state !== nextState) { + const next = select(nextState, sessionId); + if (state === undefined || !equal(selection, next)) selection = next; + state = nextState; + } + return selection; + }; + }, [bridge, sessionId]); + const overlay = useSyncExternalStore(bridge.subscribe, getSnapshot, getSnapshot); + return { bridge, commands: bridge.commands, overlay }; +} 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 index a42d449c37..5497c69b01 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts @@ -33,6 +33,7 @@ export function createDesktopSessionSettingsServices( bridge: DesktopSessionSettingsBridge = window.maka, ): SessionSettingsServices { return { + getPlanState: (sessionId) => bridge.sessions.getPlanState(sessionId), setModelConfiguration: async (sessionId, input) => expectSessionUpdate(await bridge.sessions.setModelConfiguration(sessionId, input)), setPermissionMode: async (sessionId, mode) => diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 4ea43c894b..73a61532a8 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 292 files — blocker 0, reimplementation 0, polish 4, aligned 288. +**Totals:** 293 files — blocker 0, reimplementation 0, polish 4, aligned 289. ## Exclusions (explicit) @@ -103,6 +103,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `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/session-settings/ui/session-settings-provider.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/task-entry/ui/task-entry-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 426fdc83bd..9dbb725678 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -74,6 +74,7 @@ apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request 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/session-settings/ui/session-settings-provider.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/task-entry/ui/task-entry-provider.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 4fc61e064c..ab27b785a3 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -134,6 +134,8 @@ export const ALLOWED = { // facade on this fiber. useSessionNavigationReads: 1, useSessionCollaborationDialog: 1, + // A selected-Session overlay read only; SessionSettingsProvider owns the + // write controller. The shell still derives its model and mode controls. useSessionSettingIntent: 1, useShellAppearance: 1, useShellChatModel: 1,