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 new file mode 100644 index 0000000000..bba8c3f5f2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts @@ -0,0 +1,119 @@ +/** + * Plan and orchestration are two Session fields with two lifetimes, so they + * are two channels here, and each writes only its own field. A Plan excursion + * that cleared the orchestration default would lose it for the execution the + * plan was written for — Runtime leaves Plan by itself on approval, and the + * default has to still be there when it does. + */ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import type { IpcMain } from 'electron'; +import type { DesktopSessionConfigurationPatch } from '../runtime-host-client.js'; +import { + registerRuntimeHostSessionCatalogIpc, + type RuntimeHostSessionCatalogIpcDeps, +} from '../runtime-host-session-catalog-ipc-main.js'; + +type Handler = (event: unknown, ...args: unknown[]) => unknown; + +/** Only the fields `toDesktopHostSessionSummary` reads back out. */ +function projection(sessionId: string) { + return { + id: sessionId, + revision: 1, + workspace: { hostCwd: '/tmp/session', target: { kind: 'path' as const } }, + name: 'Session', + isFlagged: false, + isArchived: false, + labels: [], + status: 'active' as const, + createdAt: 1, + lastUsedAt: 1, + backend: 'fake' as const, + llmConnectionSlug: 'fake', + connectionLocked: false, + model: 'fake-model', + permissionMode: 'ask' as const, + collaborationMode: 'agent' as const, + orchestrationMode: 'default' as const, + }; +} + +function harness(patches: DesktopSessionConfigurationPatch[]) { + const handlers = new Map(); + const ipcMain = { + handle(channel: string, handler: Handler) { + handlers.set(channel, handler); + }, + }; + const deps = { + client: { + async updateSessionConfiguration(sessionId: string, patch: DesktopSessionConfigurationPatch) { + patches.push(patch); + return projection(sessionId); + }, + }, + resolveCreateProject: async () => ({}), + emitSessionsChanged() {}, + releaseSessionResources() {}, + sessionCopyCleanup: { recover: async () => ({ cleaned: [], failed: [] }) }, + } as unknown as RuntimeHostSessionCatalogIpcDeps; + registerRuntimeHostSessionCatalogIpc(deps, ipcMain as unknown as IpcMain); + return { + invoke: (channel: string, ...args: unknown[]) => { + const handler = handlers.get(channel); + assert.ok(handler, `missing handler: ${channel}`); + return handler({}, ...args); + }, + channels: handlers, + }; +} + +test('entering or leaving Plan writes the collaboration field alone', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await ipc.invoke('sessions:setCollaborationMode', 'session-1', 'plan'); + await ipc.invoke('sessions:setCollaborationMode', 'session-1', 'agent'); + + assert.deepEqual(patches, [{ collaborationMode: 'plan' }, { collaborationMode: 'agent' }]); +}); + +test('the orchestration default writes its own field alone', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'swarm'); + await ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'default'); + + assert.deepEqual(patches, [{ orchestrationMode: 'swarm' }, { orchestrationMode: 'default' }]); +}); + +test('a Plan Session keeps the orchestration default it was carrying', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'swarm'); + await ipc.invoke('sessions:setCollaborationMode', 'session-1', 'plan'); + + // Nothing in the Plan write names `orchestrationMode`, so the merge at the + // Host leaves Swarm standing. Plan strips the tools it needs for as long as + // the excursion lasts; it does not end it. + assert.deepEqual(patches[1], { collaborationMode: 'plan' }); + assert.equal('orchestrationMode' in (patches[1] ?? {}), false); +}); + +test('an unknown mode is refused rather than persisted', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await assert.rejects( + ipc.invoke('sessions:setCollaborationMode', 'session-1', 'swarm') as Promise, + /Invalid collaboration mode/, + ); + await assert.rejects( + ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'plan') as Promise, + /Invalid orchestration mode/, + ); + assert.deepEqual(patches, [], 'nothing reached the Host'); +}); 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 96f2169258..304d43e86a 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 @@ -157,6 +157,12 @@ export function registerRuntimeHostSessionCatalogIpc( if (!isPermissionMode(mode)) throw new Error(`Invalid permission mode: ${String(mode)}`); return updateConfiguration(deps, sessionId, { permissionMode: mode }, 'mode-change'); }); + // Two fields, two channels, one field each. Plan is a temporary + // collaboration excursion that Runtime ends by itself on approval or + // abandonment; orchestration is the Session's standing default for how a + // turn fans out. Runtime resolves the overlap by stripping the subagent and + // agent-graph tools while planning, and validates the two independently, so + // neither channel has any business writing the other's field. ipcMain.handle( 'sessions:setCollaborationMode', async (_event, sessionId: string, mode: unknown) => { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 3e0ff10b8c..2a9c101915 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -594,7 +594,16 @@ export interface MakaBridge { setFlagged(sessionId: string, isFlagged: boolean, options?: { revisionFamily?: boolean }): Promise; rename(sessionId: string, name: string, options?: { revisionFamily?: boolean }): Promise; setPermissionMode(sessionId: string, mode: PermissionMode): Promise; + /** + * Enter or leave Plan — a temporary collaboration excursion Runtime ends + * by itself once a proposal is approved or abandoned. + */ setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise; + /** + * The Session's standing default for how a turn fans out. Independent of + * Plan: different field, different lifetime, and Runtime resolves the + * overlap by stripping the tools Swarm and Graph need while planning. + */ setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise; getPlanState(sessionId: string): Promise; subscribePlanChanges(sessionId: string, handler: () => void): () => void; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f17b8ea656..60863bf20f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -80,6 +80,7 @@ import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; + import type { TurnOrchestration, SessionListFilter, RegenerateTurnInput } from '@maka/core/runtime-inputs'; import type { PlanSessionState } from '@maka/core/plan'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d16786aee4..c13a92d8d7 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -13,6 +13,7 @@ import type { ScheduledTask } from '@maka/core/scheduled-task'; import type { ProjectRecord } from '@maka/core/project'; import type { QuoteRef } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; +import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; @@ -417,14 +418,15 @@ function AppShellContent({ removeQuote, clearQuotes, } = useAppShellComposerQuotes({ draftKey: attachmentDraftKey }); + // What a new chat will start with, held the way the Session holds it: a + // Plan toggle and one orchestration value, not one fused choice. const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); + const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); const [pendingCollaborationModeBySession, setPendingCollaborationModeBySession] = useState>({}); - const [newChatSwarmModeActive, setNewChatSwarmModeActive] = useState(false); - const [newChatGraphModeActive, setNewChatGraphModeActive] = useState(false); + const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState>({}); const [newTaskPermissionChoice, setNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); - const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState>({}); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; @@ -920,6 +922,8 @@ function AppShellContent({ const pendingTurnActions = turnActionRegistry.keys; const sessionRowActionRegistry = useKeyedPendingRegistry(); const permissionModeChangeRegistry = useKeyedPendingRegistry(); + // One registry per persisted field. The two controls are independent, so a + // Plan transition in flight is no reason to hold the orchestration choice. const collaborationModeChangeRegistry = useKeyedPendingRegistry(); const orchestrationModeChangeRegistry = useKeyedPendingRegistry(); const sessionModelChangeRegistry = useKeyedPendingRegistry(); @@ -1007,17 +1011,30 @@ function AppShellContent({ toastApi, }); - async function setPlanMode(active: boolean): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) { - setNewChatPlanModeActive(active); - return; - } + /** + * 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 applyPlanMode(active: boolean, sessionId: string): Promise { if (!addPendingSessionAction( sessionId, collaborationModeChangeRegistry.keysRef, setPendingCollaborationModeBySession, - )) return; + )) return false; try { const planState = await window.maka.sessions.getPlanState(sessionId); @@ -1026,7 +1043,7 @@ function AppShellContent({ shellCopy.planModeExecutionActiveTitle, shellCopy.planModeExecutionActiveDescription, ); - return; + return false; } const latestProposal = planState.proposals.find( (proposal) => proposal.proposalId === planState.latestProposalId, @@ -1039,7 +1056,9 @@ function AppShellContent({ cancelLabel: shellCopy.planModeExitCancel, destructive: true, }); - if (!confirmed) return; + if (!confirmed) return false; + // Abandoning the proposal is what leaves Plan: Runtime writes the + // Session back to `agent` itself as part of it. await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); setSessions((current) => current.map((session) => ( session.id === sessionId ? { ...session, collaborationMode: 'agent' } : session @@ -1052,6 +1071,7 @@ function AppShellContent({ setSessions((current) => current.map((session) => session.id === next.id ? next : session)); } await refreshSessions(); + return true; } catch (error) { if (activeIdRef.current === sessionId) { toastApi.error( @@ -1059,6 +1079,7 @@ function AppShellContent({ localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale), ); } + return false; } finally { clearPendingSessionAction( sessionId, @@ -1068,13 +1089,17 @@ function AppShellContent({ } } - async function setSwarmMode(active: boolean): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) { - setNewChatSwarmModeActive(active); - if (active) setNewChatGraphModeActive(false); - return true; - } + /** + * Set the Session's standing orchestration default — the only path that + * writes `orchestrationMode`, and it writes nothing else. + * + * One field with three values, so there is nothing to sequence and nothing + * to leave half-applied: Swarm, Graph and off are one write each. + */ + async function applyOrchestrationMode( + mode: OrchestrationMode, + sessionId: string, + ): Promise { if (!addPendingSessionAction( sessionId, orchestrationModeChangeRegistry.keysRef, @@ -1082,18 +1107,15 @@ function AppShellContent({ )) return false; try { - const next = await window.maka.sessions.setOrchestrationMode( - sessionId, - active ? 'swarm' : 'default', - ); + const next = await window.maka.sessions.setOrchestrationMode(sessionId, mode); setSessions((current) => current.map((session) => session.id === next.id ? next : session)); await refreshSessions(); return true; } catch (error) { if (activeIdRef.current === sessionId) { toastApi.error( - shellCopy.swarmModeFailedTitle, - localizedShellErrorMessage(error, shellCopy.swarmModeFallback, uiLocale), + shellCopy.orchestrationModeFailedTitle, + localizedShellErrorMessage(error, shellCopy.orchestrationModeFallback, uiLocale), ); } return false; @@ -1106,42 +1128,40 @@ function AppShellContent({ } } - async function setGraphMode(active: boolean): Promise { + function setPlanMode(active: boolean): Promise { const sessionId = activeIdRef.current; if (!sessionId) { - setNewChatGraphModeActive(active); - if (active) setNewChatSwarmModeActive(false); - return true; + setNewChatPlanModeActive(active); + return Promise.resolve(true); } - if (!addPendingSessionAction( - sessionId, - orchestrationModeChangeRegistry.keysRef, - setPendingOrchestrationModeBySession, - )) return false; + if (active === activePlanMode) return Promise.resolve(true); + return applyPlanMode(active, sessionId); + } - try { - const next = await window.maka.sessions.setOrchestrationMode( - sessionId, - active ? 'graph' : 'default', - ); - setSessions((current) => current.map((session) => session.id === next.id ? next : session)); - await refreshSessions(); - return true; - } catch (error) { - if (activeIdRef.current === sessionId) { - toastApi.error( - shellCopy.graphModeFailedTitle, - localizedShellErrorMessage(error, shellCopy.graphModeFallback, uiLocale), - ); - } - return false; - } finally { - clearPendingSessionAction( - sessionId, - orchestrationModeChangeRegistry.keysRef, - setPendingOrchestrationModeBySession, - ); + /** + * The + menu's orchestration choice and the `/swarm` and `/graph` commands + * all land here, so every entry point spells the field the same way. + * + * `/swarm off` means "leave swarm", not "go to default": a Session already + * in Graph has nothing for it to do. + */ + function setOrchestrationMode(mode: OrchestrationMode): Promise { + const sessionId = activeIdRef.current; + if (!sessionId) { + setNewChatOrchestrationMode(mode); + return Promise.resolve(true); } + if (mode === activeOrchestrationMode) return Promise.resolve(true); + return applyOrchestrationMode(mode, sessionId); + } + + function setOrchestrationModeActive( + mode: Exclude, + active: boolean, + ): Promise { + if (active) return setOrchestrationMode(mode); + if (activeOrchestrationMode !== mode) return Promise.resolve(true); + return setOrchestrationMode('default'); } // Handed to ChatView, which calls it with the turns its transcript projection @@ -1291,6 +1311,29 @@ function AppShellContent({ permissionMode: defaultPermissionMode, } : undefined); + // Each control reads its own field. There is nothing to project and nothing + // to keep in sync: a Session in Plan with Swarm as its orchestration default + // says both, because it is both. + const activePlanMode = activeId + ? (activeSessionForView?.collaborationMode ?? 'agent') === 'plan' + : newChatPlanModeActive; + const activeOrchestrationMode: OrchestrationMode = activeId + ? activeSessionForView?.orchestrationMode ?? 'default' + : newChatOrchestrationMode; + /** + * Why neither mode can be changed right now, if either cannot. Both controls + * write the same Session configuration, so everything that holds one holds + * the other; only "this one is already changing" is per-control. + */ + const modeChangeDisabledReason = activeId && !activeSession + ? shellCopy.modeChangeLoading + : activeStreamingLive + ? shellCopy.modeChangeStreaming + : activeId && turnActive + ? shellCopy.modeChangeRunning + : activeId && activeSessionForView?.status === 'waiting_for_user' + ? shellCopy.modeChangeWaiting + : undefined; const { boundary: activeExecutionBoundary, unreadable: activeExecutionBoundaryUnreadable, @@ -2085,11 +2128,7 @@ function AppShellContent({ pendingNewChatThinkingLevel: newChatThinkingLevel ?? null, newChatPermissionMode: newTaskPermissionMode, newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', - newChatOrchestrationMode: newChatGraphModeActive - ? 'graph' - : newChatSwarmModeActive - ? 'swarm' - : 'default', + newChatOrchestrationMode: newChatOrchestrationMode, newTaskTarget: newTask.target, }); @@ -2215,9 +2254,7 @@ function AppShellContent({ if (slashCommand?.kind === 'swarm') { const swarmCommand = slashCommand.command; if (swarmCommand.kind === 'status') { - const active = activeIdRef.current - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' - : newChatSwarmModeActive; + const active = activeOrchestrationMode === 'swarm'; toastApi.info( active ? shellCopy.swarmModeEnabledTitle : shellCopy.swarmModeDisabledTitle, shellCopy.swarmModeStatusDescription, @@ -2225,7 +2262,7 @@ function AppShellContent({ return true; } if (swarmCommand.kind === 'set_mode') { - const changed = await setSwarmMode(swarmCommand.mode === 'swarm'); + const changed = await setOrchestrationModeActive('swarm', swarmCommand.mode === 'swarm'); if (changed) { toastApi.info( swarmCommand.mode === 'swarm' @@ -2258,9 +2295,7 @@ function AppShellContent({ if (slashCommand?.kind === 'graph') { const graphCommand = slashCommand.command; if (graphCommand.kind === 'status') { - const active = activeIdRef.current - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' - : newChatGraphModeActive; + const active = activeOrchestrationMode === 'graph'; toastApi.info( active ? shellCopy.graphModeEnabledTitle : shellCopy.graphModeDisabledTitle, shellCopy.graphModeStatusDescription, @@ -2272,7 +2307,7 @@ function AppShellContent({ return true; } if (graphCommand.kind === 'set_mode') { - const changed = await setGraphMode(graphCommand.mode === 'graph'); + const changed = await setOrchestrationModeActive('graph', graphCommand.mode === 'graph'); if (changed) { toastApi.info( graphCommand.mode === 'graph' @@ -2665,6 +2700,8 @@ function AppShellContent({ function openNewTaskSurface() { startNewSession(); + // Only Plan resets: a new task starts out of Plan, in whatever + // orchestration the last one was set to. setNewChatPlanModeActive(false); setNavSelection({ section: 'sessions' }); setSearchScrollTarget(null); @@ -3219,57 +3256,29 @@ function AppShellContent({ ? (mode) => setPermissionMode(mode) : undefined } - planModeActive={activeId - ? (activeSessionForView?.collaborationMode ?? 'agent') === 'plan' - : newChatPlanModeActive} - planModePending={activeId ? pendingCollaborationModeBySession[activeId] === true : false} + planModeActive={activePlanMode} + planModePending={activeId + ? pendingCollaborationModeBySession[activeId] === true + : false} planModeDisabledReason={ activeId && pendingCollaborationModeBySession[activeId] === true - ? shellCopy.planModeChanging - : activeStreamingLive - ? shellCopy.planModeStreaming - : activeId && turnActive - ? shellCopy.planModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.planModeWaiting - : undefined + ? shellCopy.modeChanging + : modeChangeDisabledReason } - onPlanModeChange={setPlanMode} - swarmModeActive={activeId - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' - : newChatSwarmModeActive} - swarmModePending={activeId ? pendingOrchestrationModeBySession[activeId] === true : false} - swarmModeDisabledReason={ - activeId && pendingOrchestrationModeBySession[activeId] === true - ? shellCopy.swarmModeChanging - : activeStreamingLive - ? shellCopy.swarmModeStreaming - : activeId && turnActive - ? shellCopy.swarmModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.swarmModeWaiting - : undefined - } - onSwarmModeChange={(active) => { - void setSwarmMode(active); + onPlanModeChange={(active) => { + void setPlanMode(active); }} - graphModeActive={activeId - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' - : newChatGraphModeActive} - graphModePending={activeId ? pendingOrchestrationModeBySession[activeId] === true : false} - graphModeDisabledReason={ + orchestrationMode={activeOrchestrationMode} + orchestrationModePending={activeId + ? pendingOrchestrationModeBySession[activeId] === true + : false} + orchestrationModeDisabledReason={ activeId && pendingOrchestrationModeBySession[activeId] === true - ? shellCopy.graphModeChanging - : activeStreamingLive - ? shellCopy.graphModeStreaming - : activeId && turnActive - ? shellCopy.graphModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.graphModeWaiting - : undefined + ? shellCopy.modeChanging + : modeChangeDisabledReason } - onGraphModeChange={(active) => { - void setGraphMode(active); + onOrchestrationModeChange={(mode) => { + void setOrchestrationMode(mode); }} /> diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 3329a6e36e..d9b31a3b51 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -436,33 +436,26 @@ type ShellCopy = { permissionModeStreaming: string; permissionModeRunning: string; permissionModeWaiting: string; - planModeChanging: string; - planModeStreaming: string; - planModeRunning: string; - planModeWaiting: string; + /** The one mode control locks for the same four reasons, worded once. */ + /** The Session summary has not arrived, so its mode is not known yet. */ + modeChangeLoading: string; + modeChanging: string; + modeChangeStreaming: string; + modeChangeRunning: string; + modeChangeWaiting: string; planModeFailedTitle: string; planModeFallback: string; + orchestrationModeFailedTitle: string; + orchestrationModeFallback: string; planModeExitPendingTitle: string; planModeExitPendingDescription(title: string): string; planModeExitConfirm: string; planModeExitCancel: string; planModeExecutionActiveTitle: string; planModeExecutionActiveDescription: string; - swarmModeChanging: string; - swarmModeStreaming: string; - swarmModeRunning: string; - swarmModeWaiting: string; - swarmModeFailedTitle: string; - swarmModeFallback: string; swarmModeEnabledTitle: string; swarmModeDisabledTitle: string; swarmModeStatusDescription: string; - graphModeChanging: string; - graphModeStreaming: string; - graphModeRunning: string; - graphModeWaiting: string; - graphModeFailedTitle: string; - graphModeFallback: string; graphModeEnabledTitle: string; graphModeDisabledTitle: string; graphModeStatusDescription: string; @@ -1145,12 +1138,15 @@ const SHELL_COPY_BY_LOCALE = { permissionModeStreaming: '当前任务正在流式输出,等结束后再切换权限模式。', permissionModeRunning: '当前任务正在运行,等结束后再切换权限模式。', permissionModeWaiting: '当前有工具调用正在等待确认,处理后再切换权限模式。', - planModeChanging: 'Plan Mode 正在切换,完成后再继续操作。', - planModeStreaming: '当前任务正在流式输出,等结束后再切换 Plan Mode。', - planModeRunning: '当前任务正在运行,等结束后再切换 Plan Mode。', - planModeWaiting: '当前有工具调用正在等待确认,处理后再切换 Plan Mode。', - planModeFailedTitle: '切换 Plan Mode 失败', - planModeFallback: 'Plan Mode 暂时无法切换,请稍后重试。', + modeChangeLoading: '会话还在载入,稍候即可切换模式。', + modeChanging: '模式正在切换,完成后再继续操作。', + modeChangeStreaming: '当前任务正在流式输出,等结束后再切换模式。', + modeChangeRunning: '当前任务正在运行,等结束后再切换模式。', + modeChangeWaiting: '当前有工具调用正在等待确认,处理后再切换模式。', + planModeFailedTitle: '切换 Plan 模式失败', + planModeFallback: 'Plan 模式暂时无法切换,请稍后重试。', + orchestrationModeFailedTitle: '切换编排模式失败', + orchestrationModeFallback: '编排模式暂时无法切换,请稍后重试。', planModeExitPendingTitle: '放弃当前方案?', planModeExitPendingDescription: (title: string) => `「${title}」尚未审批。退出 Plan Mode 后,该方案会标记为已放弃,但历史记录仍会保留。`, @@ -1158,21 +1154,9 @@ const SHELL_COPY_BY_LOCALE = { planModeExitCancel: '继续规划', planModeExecutionActiveTitle: '计划仍在执行', planModeExecutionActiveDescription: '请先中断当前执行,再进入 Plan Mode 调整方案。', - swarmModeChanging: 'Swarm Mode 正在切换,完成后再继续操作。', - swarmModeStreaming: '当前任务正在流式输出,等结束后再切换 Swarm Mode。', - swarmModeRunning: '当前任务正在运行,等结束后再切换 Swarm Mode。', - swarmModeWaiting: '当前有工具调用正在等待确认,处理后再切换 Swarm Mode。', - swarmModeFailedTitle: '切换 Swarm Mode 失败', - swarmModeFallback: 'Swarm Mode 暂时无法切换,请稍后重试。', swarmModeEnabledTitle: 'Swarm Mode 已开启', swarmModeDisabledTitle: 'Swarm Mode 未开启', swarmModeStatusDescription: '使用 /swarm on、/swarm off,或 /swarm <任务> 单次运行。', - graphModeChanging: 'Graph Mode 正在切换,完成后再继续操作。', - graphModeStreaming: '当前任务正在流式输出,等结束后再切换 Graph Mode。', - graphModeRunning: '当前任务正在运行,等结束后再切换 Graph Mode。', - graphModeWaiting: '当前有工具调用正在等待确认,处理后再切换 Graph Mode。', - graphModeFailedTitle: '切换 Graph Mode 失败', - graphModeFallback: 'Graph Mode 暂时无法切换,请稍后重试。', graphModeEnabledTitle: 'Graph Mode 已开启', graphModeDisabledTitle: 'Graph Mode 未开启', graphModeStatusDescription: '使用 /graph on、/graph off,或 /graph <任务> 单次运行。', @@ -1681,12 +1665,15 @@ const SHELL_COPY_BY_LOCALE = { '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.', permissionModeWaiting: 'A tool call is waiting for confirmation. Respond before changing the permission mode.', - planModeChanging: 'Plan Mode is changing. Wait for it to finish before continuing.', - planModeStreaming: 'This task is streaming. Wait for it to finish before changing Plan Mode.', - planModeRunning: 'This task is running. Wait for it to finish before changing Plan Mode.', - planModeWaiting: 'A tool call is waiting for confirmation. Respond before changing Plan Mode.', - planModeFailedTitle: 'Could not change Plan Mode', - planModeFallback: 'Plan Mode could not be changed. Try again later.', + modeChangeLoading: 'This session is still loading. Its mode can be changed in a moment.', + modeChanging: 'The mode is changing. Wait for it to finish before continuing.', + modeChangeStreaming: 'This task is streaming. Wait for it to finish before changing the mode.', + modeChangeRunning: 'This task is running. Wait for it to finish before changing the mode.', + modeChangeWaiting: 'A tool call is waiting for confirmation. Respond before changing the mode.', + planModeFailedTitle: 'Could not change Plan mode', + planModeFallback: 'Plan mode could not be changed. Try again later.', + orchestrationModeFailedTitle: 'Could not change the orchestration mode', + orchestrationModeFallback: 'The orchestration mode could not be changed. Try again later.', planModeExitPendingTitle: 'Abandon the current plan?', planModeExitPendingDescription: (title: string) => `“${title}” has not been approved. Leaving Plan Mode will mark it as abandoned while preserving its history.`, @@ -1694,21 +1681,9 @@ const SHELL_COPY_BY_LOCALE = { planModeExitCancel: 'Keep planning', planModeExecutionActiveTitle: 'The plan is still running', planModeExecutionActiveDescription: 'Interrupt the active execution before entering Plan Mode to revise it.', - swarmModeChanging: 'Swarm Mode is changing. Wait for it to finish before continuing.', - swarmModeStreaming: 'This task is streaming. Wait for it to finish before changing Swarm Mode.', - swarmModeRunning: 'This task is running. Wait for it to finish before changing Swarm Mode.', - swarmModeWaiting: 'A tool call is waiting for confirmation. Respond before changing Swarm Mode.', - swarmModeFailedTitle: 'Could not change Swarm Mode', - swarmModeFallback: 'Swarm Mode could not be changed. Try again later.', swarmModeEnabledTitle: 'Swarm Mode is on', swarmModeDisabledTitle: 'Swarm Mode is off', swarmModeStatusDescription: 'Use /swarm on, /swarm off, or /swarm for one turn.', - graphModeChanging: 'Graph Mode is changing. Wait for it to finish before continuing.', - graphModeStreaming: 'This task is streaming. Wait for it to finish before changing Graph Mode.', - graphModeRunning: 'This task is running. Wait for it to finish before changing Graph Mode.', - graphModeWaiting: 'A tool call is waiting for confirmation. Respond before changing Graph Mode.', - graphModeFailedTitle: 'Could not change Graph Mode', - graphModeFallback: 'Graph Mode could not be changed. Try again later.', graphModeEnabledTitle: 'Graph Mode is on', graphModeDisabledTitle: 'Graph Mode is off', graphModeStatusDescription: 'Use /graph on, /graph off, or /graph for one turn.', diff --git a/apps/desktop/src/renderer/styles/astryx-mount.css b/apps/desktop/src/renderer/styles/astryx-mount.css index 5f0e0a9d34..e6c7e08008 100644 --- a/apps/desktop/src/renderer/styles/astryx-mount.css +++ b/apps/desktop/src/renderer/styles/astryx-mount.css @@ -61,6 +61,8 @@ with the value beside it. */ .maka-model-selection-controls, .maka-composer-workspace, +.maka-composer-plus-menu, +.permissionModeIcon, .maka-inspector-panel { --color-background-surface: var(--background); --color-background-popover: var(--background-elevated); diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index e3b53e2358..803302911e 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -158,16 +158,37 @@ /* Cursor: product-wide native-cursor.css (maka.legacy) owns default vs pointer. */ /* Astryx sm list density: DropdownMenuItem + Selector options use - padding-block: var(--spacing-1) (4px) when size=sm. RadioItem only sizes its - radio glyph from menuSize and never applies itemSizeStyles, so it stays on - Item balanced (8px) and permission rows look taller than + / model. Quiet - menus force the sm block pad so all three composer popouts share one ladder. - Use product --space-1 (same 4px step as Astryx --spacing-1). Upstream - should wire itemSizeStyles into RadioItem; drop this when it does. */ -.maka-composer-quiet-menu [role="menuitemradio"] { + padding-block: var(--spacing-1) (4px) when size=sm. The two selectable item + types only size their glyph from menuSize and never apply itemSizeStyles, so + they stay on Item balanced (8px) and their rows look taller than + / model. + Quiet menus force the sm block pad so all three composer popouts share one + ladder. Use product --space-1 (same 4px step as Astryx --spacing-1). + Upstream should wire itemSizeStyles into both; drop this when it does. */ +.maka-composer-quiet-menu [role="menuitemradio"], +.maka-composer-quiet-menu [role="menuitemcheckbox"] { padding-block: var(--space-1); } +/* The + menu's mode rows carry the mark Astryx draws for a chosen option — a + check when chosen, nothing when not — which is what its own Selector puts on + a selected option, and what a menu of otherwise identical rows needs: no + column of empty boxes and circles ahead of the labels, and the mode icons on + the same x as the action rows above. Both selectable item types draw their + own control at the row's start, so composer.tsx passes the check through + `endContent` and the controls are suppressed here. + + `astryx-checkbox-indicator` and `astryx-radio-indicator` are the two + indicators' published theme targets (see their themeProps calls), not + internal classes. Scoped to the + panel alone: nothing outside this menu is + touched. + + Drop these rules and the `endContent` mark together once a selectable item + can choose its own indicator upstream. */ +.maka-composer-plus-panel [role="menuitemcheckbox"] .astryx-checkbox-indicator, +.maka-composer-plus-panel [role="menuitemradio"] .astryx-radio-indicator { + display: none; +} + /* Astryx sizes the popover to its widest row (min-width:anchor-size, no max). The model menu lost the 260px/56vw option-label cap with the Selector, so the menu itself needs the ceiling: a long model name (BYOK catalogs love diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index f94ede2f16..a9f7408703 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -225,15 +225,13 @@ const baseComposerProps: ComposerProps = { onPermissionModeChange: noop, // Fidelity: production app-shell always wires these (app-shell.tsx // ~1851-1960), so the daily composer renders the upload button, the - // modes menu (Plan / Swarm), and the Skills picker. Omitting them here - // understated the persistent element count in every shell story. + // mode controls (Plan / orchestration), and the Skills picker. Omitting them + // here understated the persistent element count in every shell story. onPickAttachments: noop, planModeActive: false, onPlanModeChange: noop, - swarmModeActive: false, - onSwarmModeChange: noop, - graphModeActive: false, - onGraphModeChange: noop, + orchestrationMode: 'default', + onOrchestrationModeChange: noop, // Thinking is a separate right-footer Selector when levels are offered. activeThinkingLevels: ['off', 'low', 'medium', 'high', 'xhigh'], activeThinkingLevel: 'medium', @@ -1003,15 +1001,16 @@ export const PlanModeOn: Story = { // product accent, so the icon is what has to keep the modes distinguishable — // this story is where that carries its own weight. export const SwarmModeOn: Story = { - render: () => , + render: () => , }; -// Real path: Plan and Swarm are independent switches (collaborationMode vs -// orchestrationMode), so both can be on at once. This is the widest the mode -// tail ever gets next to a real model name. +// Real path: Plan and orchestration are separate Session fields with separate +// lifetimes, so both can be on at once — Plan is a temporary excursion, Swarm +// is the standing default the execution afterwards runs under. This is the +// widest the mode tail ever gets next to a real model name. export const PlanAndSwarmModeOn: Story = { render: () => ( - + ), }; diff --git a/packages/ui/src/__tests__/composer-plus-menu.test.tsx b/packages/ui/src/__tests__/composer-plus-menu.test.tsx new file mode 100644 index 0000000000..ee58cc530a --- /dev/null +++ b/packages/ui/src/__tests__/composer-plus-menu.test.tsx @@ -0,0 +1,95 @@ +/** + * The + menu's mode rows and the divider above them. + * + * Each row gets the control its field is. Plan is a Session field of its own + * and an independent switch. Swarm and Graph are the two values of one other + * field, so they are one group and picking one is picking away from the other + * — announced as a set rather than left for a screen reader to miss. Neither + * is chosen at rest, and no row stands for that; every prop that feeds them is + * optional, so a host can wire the modes alone, and then there is nothing + * above the divider to divide. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { Composer } from '../composer.js'; +import { LocaleProvider } from '../locale-context.js'; + +function render(props: Parameters[0]): string { + return renderToStaticMarkup( + + + , + ); +} + +function plusMenu(props: Parameters[0]): string { + const parts = render(props).split('maka-composer-plus-menu'); + // Without the marker `split` returns the whole markup, and a menu that + // stopped rendering would still satisfy an absence assertion. + assert.ok(parts.length > 1, 'the composer rendered no + menu'); + return parts[parts.length - 1] ?? ''; +} + +function count(markup: string, needle: string): number { + return markup.split(needle).length - 1; +} + +/** Opening tags carrying every one of these attributes, in any order. */ +function tagsWith(markup: string, ...attributes: readonly string[]): readonly string[] { + return (markup.match(/<[a-z]+[^>]*>/g) ?? []).filter( + (tag) => attributes.every((attribute) => tag.includes(attribute)), + ); +} + +const base = { + onSend: () => undefined, + onStop: () => undefined, + planModeActive: false, + onPlanModeChange: () => undefined, + orchestrationMode: 'default' as const, + onOrchestrationModeChange: () => undefined, +}; + +test('the mode controls alone open the menu on a row, not on a rule', () => { + assert.equal(plusMenu(base).includes('astryx-dropdown-menu-divider'), false); +}); + +test('an action row above the mode controls keeps the divider', () => { + const withAction = plusMenu({ ...base, onPickAttachments: () => undefined }); + assert.equal(withAction.includes('astryx-dropdown-menu-divider'), true); +}); + +test('each mode row is the control its field is, and none of them is on', () => { + const menu = plusMenu(base); + assert.equal(count(menu, 'role="menuitemcheckbox"'), 1, 'Plan alone is a switch'); + // Two rows, not three: the field's third value is this group holding none. + assert.equal(count(menu, 'role="menuitemradio"'), 2, 'Swarm and Graph, no neutral row'); + assert.equal( + tagsWith(menu, 'role="group"', 'aria-label="Orchestration mode"').length, + 1, + 'the exclusive pair is announced as one named set', + ); + assert.equal(count(menu, 'aria-checked="true"'), 0, 'nothing on is nothing checked'); +}); + +test('Plan and an orchestration mode are both on at once', () => { + const markup = render({ ...base, planModeActive: true, orchestrationMode: 'swarm' }); + const menu = markup.split('maka-composer-plus-menu')[1] ?? ''; + assert.equal( + tagsWith(menu, 'role="menuitemcheckbox"', 'aria-checked="true"').length, + 1, + 'Plan is not checked', + ); + assert.equal( + tagsWith(menu, 'role="menuitemradio"', 'aria-checked="true"').length, + 1, + 'Swarm is not checked, or Graph is checked with it', + ); + // Each one keeps its own readout and its own way out, so neither hides the + // other: a Plan excursion does not clear the orchestration default. + assert.equal(count(markup, 'maka-composer-mode-button'), 2); + assert.ok(markup.includes('data-mode="plan"')); + assert.ok(markup.includes('data-mode="swarm"')); +}); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index a5c2695ff3..c1185e99e8 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -55,6 +55,7 @@ import { import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import type { AttachmentRef, QuoteRef } from '@maka/core/events'; import type { PermissionMode } from '@maka/core/permission'; +import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ProviderType } from '@maka/core/llm-connections'; import type { SessionSummary } from '@maka/core/session'; import { @@ -76,8 +77,12 @@ import { import { DropdownMenu, DropdownMenuCheckboxItem, + DropdownMenuDivider, DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, } from '@astryxdesign/core/DropdownMenu'; +import { useIndicator } from '@astryxdesign/core/Indicator'; import { PermissionModeSelect } from './permission-mode-menu.js'; import { AttachmentKindIcon } from './attachment-kinds.js'; import { formatPreviewSize } from './artifact-preview-registry.js'; @@ -333,22 +338,36 @@ export const Composer = forwardRef< permissionModeDisabledReason?: string; onPermissionModeChange?(mode: PermissionMode): void | Promise; /** - * Session collaboration mode switch. Agent mode is the implicit default, - * so the composer only exposes whether Plan mode is enabled. + * Plan mode — a temporary collaboration excursion, and a toggle because + * that is what it is. Agent is the implicit default, so the composer only + * carries whether Plan is on. Runtime ends the excursion by itself when a + * proposal is approved or abandoned, which is why nothing here treats Plan + * as a resting mode the user must leave by hand. */ planModeActive?: boolean; planModePending?: boolean; planModeDisabledReason?: string; onPlanModeChange?(active: boolean): void | Promise; - /** Session orchestration mode switch. Default mode remains the implicit fallback. */ - swarmModeActive?: boolean; - swarmModePending?: boolean; - swarmModeDisabledReason?: string; - onSwarmModeChange?(active: boolean): void | Promise; - graphModeActive?: boolean; - graphModePending?: boolean; - graphModeDisabledReason?: string; - onGraphModeChange?(active: boolean): void | Promise; + /** + * The Session's standing orchestration default. Of the field's three + * values only Swarm and Graph name a way to fan a turn out; `default` is + * the absence of one, so this is an optional choice between two rather + * than a choice among three. The two are exclusive — a run carries one + * orchestration — which is why the menu offers them as a radio group with + * no selection at rest, not as two switches that would silently turn each + * other off. + * + * Independent of Plan on purpose. The two are different fields with + * different lifetimes: Plan gates which tools a turn gets, this names how + * a turn fans out by default, and Runtime resolves the overlap by + * stripping the subagent and agent-graph tools while planning. So "plan + * with Swarm armed for afterwards" is a state the Session can hold, and + * neither control writes the other's field. + */ + orchestrationMode?: OrchestrationMode; + orchestrationModePending?: boolean; + orchestrationModeDisabledReason?: string; + onOrchestrationModeChange?(mode: OrchestrationMode): void | Promise; /** * Composer mention popups. Both are optional and the whole feature no-ops * when absent (SSR contracts render Composer with minimal props): @@ -1284,75 +1303,118 @@ export const Composer = forwardRef< setAttachmentLightbox(null); }, [attachmentLightboxOpen, attachmentLightbox]); /** - * The session modes that are currently on, in the order the + menu lists - * them. The menu stays the switch — it turns each mode on *and* off; these - * marks are the resting state readout, plus one nearby way out. They sit at - * the tail of the footer's left controls, after the model and thinking - * pickers, so switching a mode never shifts those two. + * The orchestration modes the + menu offers — the field's two real values. * - * Which mode a mark is comes from its icon, never from a hue. Maka blue is - * the single product accent (DESIGN.md), so a per-mode colour would be a - * second and third accent carrying no semantic — and a coloured pill per - * status is on the same file's Don't list. + * `default` is not among them and has no row: it is not a third thing to + * pick, it is what the Session is when neither of these is chosen. The group + * carries that as no selection, which is a state Astryx's radio group takes + * (`value: string | undefined`) and screen readers announce as a set with + * nothing checked. */ - const modes: ReadonlyArray<{ - id: 'plan' | 'swarm' | 'graph'; - active: boolean; + const orchestrationOptions: ReadonlyArray<{ + id: Exclude; icon: ReactNode; label: string; onTitle: string; - isDisabled: boolean; - disabledReason: string | undefined; - onDeactivate(): void; }> = [ - { - id: 'plan', - active: props.planModeActive === true && props.onPlanModeChange !== undefined, - icon: