From 9860425a4423ad30e74df971ac7c09a2dd81c8ce Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 18 Aug 2026 16:58:20 +0800 Subject: [PATCH 01/10] =?UTF-8?q?refactor(composer):=20make=20the=20?= =?UTF-8?q?=EF=BC=8B=20menu=20one=20entry=20with=20one=20kind=20of=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu mixed two Astryx item components and offered a choice the runtime cannot honour. Plan, Swarm and Graph were three independent checkbox items, but Swarm and Graph both write `orchestrationMode`, so turning one on silently turned the other off — and Plan strips subagent-category tools and the agent-graph tools (`plan-mode.ts`), which is what Swarm and Graph are made of. The four states were always one choice. So the modes become one radio group of four, `default` included because it is the way out of the other three. `sessionMode` + `onSessionModeChange` replace the three switch prop trios, and app-shell's `setSessionMode` is the single writer that sequences the two Session fields the choice lands in. Leaving Plan can be refused (a pending proposal asks first), so the chain stops there rather than applying half of the change. The rows now share one rhythm. `DropdownMenuCheckboxItem` never applies the menu's sm density, so its rows stood 36px against the 28px action rows above them, and its marker column pushed their icons 28px to the right. Radio rows already had a product rule for that density; the selection mark moves to `endContent` as Astryx's own `check` indicator — what its Selector puts on a chosen option — and composer.css suppresses the radio circle for this panel alone. Measured live in Storybook: six rows, 28px each, icons on one x. The + and permission menus also join the palette seam in astryx-mount.css. Astryx renders these panels in place rather than portaling them, so without the seam they painted from the neutral theme: the menu's own accent was near-black while the footer's mode mark beside it was Maka blue, for the same state. Generated-by: Claude Code --- apps/desktop/src/renderer/app-shell.tsx | 171 ++++++++------ .../src/renderer/locales/shell-copy.ts | 49 ++-- .../src/renderer/styles/astryx-mount.css | 2 + apps/desktop/src/renderer/styles/composer.css | 18 ++ apps/desktop/stories/app-shell.stories.tsx | 27 +-- packages/ui/src/components.tsx | 1 + packages/ui/src/composer.tsx | 218 +++++++++--------- packages/ui/src/conversation-copy.ts | 30 +-- 8 files changed, 259 insertions(+), 257 deletions(-) diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d16786aee4..0d0e4f4fc2 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -27,6 +27,7 @@ import { ChatSurfaceLayout, type ComposerHandle, type ComposerSendMetadata, + type ComposerSessionMode, type ComposerSlashCommandOption, type MakaUriDest, MakaUriContext, @@ -417,11 +418,12 @@ function AppShellContent({ removeQuote, clearQuotes, } = useAppShellComposerQuotes({ draftKey: attachmentDraftKey }); - const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); + // The mode a new chat will start in. One value, like the Session field pair + // it becomes on first send — three booleans here could hold a combination + // (Plan + Swarm) that no Session can be created in. + const [newChatSessionMode, setNewChatSessionMode] = useState('default'); const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); const [pendingCollaborationModeBySession, setPendingCollaborationModeBySession] = useState>({}); - const [newChatSwarmModeActive, setNewChatSwarmModeActive] = useState(false); - const [newChatGraphModeActive, setNewChatGraphModeActive] = useState(false); const [newTaskPermissionChoice, setNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState>({}); @@ -1007,17 +1009,25 @@ function AppShellContent({ toastApi, }); - async function setPlanMode(active: boolean): Promise { + /** + * Returns whether the session ended up in the requested collaboration mode. + * `setSessionMode` chains this with an orchestration write, and the chain + * must stop when this one is refused — a cancelled "abandon the pending + * plan" confirmation would otherwise leave Plan on with Swarm also applied. + */ + async function setPlanMode(active: boolean): Promise { const sessionId = activeIdRef.current; if (!sessionId) { - setNewChatPlanModeActive(active); - return; + setNewChatSessionMode((current) => ( + active ? 'plan' : current === 'plan' ? 'default' : current + )); + return true; } if (!addPendingSessionAction( sessionId, collaborationModeChangeRegistry.keysRef, setPendingCollaborationModeBySession, - )) return; + )) return false; try { const planState = await window.maka.sessions.getPlanState(sessionId); @@ -1026,7 +1036,7 @@ function AppShellContent({ shellCopy.planModeExecutionActiveTitle, shellCopy.planModeExecutionActiveDescription, ); - return; + return false; } const latestProposal = planState.proposals.find( (proposal) => proposal.proposalId === planState.latestProposalId, @@ -1039,7 +1049,7 @@ function AppShellContent({ cancelLabel: shellCopy.planModeExitCancel, destructive: true, }); - if (!confirmed) return; + if (!confirmed) return false; await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); setSessions((current) => current.map((session) => ( session.id === sessionId ? { ...session, collaborationMode: 'agent' } : session @@ -1052,6 +1062,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 +1070,7 @@ function AppShellContent({ localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale), ); } + return false; } finally { clearPendingSessionAction( sessionId, @@ -1071,8 +1083,9 @@ function AppShellContent({ async function setSwarmMode(active: boolean): Promise { const sessionId = activeIdRef.current; if (!sessionId) { - setNewChatSwarmModeActive(active); - if (active) setNewChatGraphModeActive(false); + setNewChatSessionMode((current) => ( + active ? 'swarm' : current === 'swarm' ? 'default' : current + )); return true; } if (!addPendingSessionAction( @@ -1109,8 +1122,9 @@ function AppShellContent({ async function setGraphMode(active: boolean): Promise { const sessionId = activeIdRef.current; if (!sessionId) { - setNewChatGraphModeActive(active); - if (active) setNewChatSwarmModeActive(false); + setNewChatSessionMode((current) => ( + active ? 'graph' : current === 'graph' ? 'default' : current + )); return true; } if (!addPendingSessionAction( @@ -1144,6 +1158,41 @@ function AppShellContent({ } } + /** + * The + menu's mode choice, written to the two Session fields that hold it. + * + * Sequenced rather than fired together: each write is its own Session + * mutation with its own pending gate, and leaving Plan can be refused (a + * pending proposal asks for confirmation first). A refusal has to stop the + * chain, or the session would come out of a cancelled dialog with the new + * orchestration applied and Plan still on — the exact combination this + * control exists to prevent. + * + * Entering Swarm or Graph needs no explicit reset of the other: both write + * the same `orchestrationMode` field, so the new value replaces the old one. + */ + async function setSessionMode(next: ComposerSessionMode): Promise { + const current = activeSessionMode; + if (next === current) return; + if (current === 'plan' && !(await setPlanMode(false))) return; + if (next === 'plan') { + if (current === 'swarm' && !(await setSwarmMode(false))) return; + if (current === 'graph' && !(await setGraphMode(false))) return; + await setPlanMode(true); + return; + } + if (next === 'swarm') { + await setSwarmMode(true); + return; + } + if (next === 'graph') { + await setGraphMode(true); + return; + } + if (current === 'swarm') await setSwarmMode(false); + else if (current === 'graph') await setGraphMode(false); + } + // Handed to ChatView, which calls it with the turns its transcript projection // produced. The shell no longer materializes the transcript a second time to // derive these props, so the turn objects the projection kept are also what @@ -1291,6 +1340,21 @@ function AppShellContent({ permissionMode: defaultPermissionMode, } : undefined); + /** + * The Session's mode as the composer offers it: one value projected from the + * two fields that carry it. Plan wins when both are set, which no session + * reaches through this control — it is how a record written by an older + * build, or by a model's own mode change, still reads as one of the four. + */ + const activeSessionMode: ComposerSessionMode = activeId + ? (activeSessionForView?.collaborationMode ?? 'agent') === 'plan' + ? 'plan' + : (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' + ? 'swarm' + : (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' + ? 'graph' + : 'default' + : newChatSessionMode; const { boundary: activeExecutionBoundary, unreadable: activeExecutionBoundaryUnreadable, @@ -2031,7 +2095,7 @@ function AppShellContent({ projectPath: activeId ? projectInfo?.projectPath : newTask.projectPath, newTaskTarget: activeId ? undefined : newTask.target, newSessionModel: newChatModel, - newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', + newSessionCollaborationMode: newChatSessionMode === 'plan' ? 'plan' : 'agent', // Refresh only; Desktop Main re-reads the authoritative default before // constructing the Runtime Host preview target. newSessionPermissionMode: newTaskPermissionMode, @@ -2084,11 +2148,10 @@ function AppShellContent({ newChatModel: newChatModel ?? null, pendingNewChatThinkingLevel: newChatThinkingLevel ?? null, newChatPermissionMode: newTaskPermissionMode, - newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', - newChatOrchestrationMode: newChatGraphModeActive - ? 'graph' - : newChatSwarmModeActive - ? 'swarm' + newChatCollaborationMode: newChatSessionMode === 'plan' ? 'plan' : 'agent', + newChatOrchestrationMode: + newChatSessionMode === 'graph' || newChatSessionMode === 'swarm' + ? newChatSessionMode : 'default', newTaskTarget: newTask.target, }); @@ -2217,7 +2280,7 @@ function AppShellContent({ if (swarmCommand.kind === 'status') { const active = activeIdRef.current ? (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' - : newChatSwarmModeActive; + : newChatSessionMode === 'swarm'; toastApi.info( active ? shellCopy.swarmModeEnabledTitle : shellCopy.swarmModeDisabledTitle, shellCopy.swarmModeStatusDescription, @@ -2260,7 +2323,7 @@ function AppShellContent({ if (graphCommand.kind === 'status') { const active = activeIdRef.current ? (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' - : newChatGraphModeActive; + : newChatSessionMode === 'graph'; toastApi.info( active ? shellCopy.graphModeEnabledTitle : shellCopy.graphModeDisabledTitle, shellCopy.graphModeStatusDescription, @@ -2665,7 +2728,10 @@ function AppShellContent({ function openNewTaskSurface() { startNewSession(); - setNewChatPlanModeActive(false); + // Only Plan resets. Swarm and Graph carried across new tasks before the + // three booleans became one value, and this keeps that: a new task starts + // out of Plan, in whatever orchestration the last one was set to. + setNewChatSessionMode((current) => (current === 'plan' ? 'default' : current)); setNavSelection({ section: 'sessions' }); setSearchScrollTarget(null); // New-task affordances reset to the empty-state composer; move focus @@ -3219,58 +3285,25 @@ function AppShellContent({ ? (mode) => setPermissionMode(mode) : undefined } - planModeActive={activeId - ? (activeSessionForView?.collaborationMode ?? 'agent') === 'plan' - : newChatPlanModeActive} - 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 - } - onPlanModeChange={setPlanMode} - swarmModeActive={activeId - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' - : newChatSwarmModeActive} - swarmModePending={activeId ? pendingOrchestrationModeBySession[activeId] === true : false} - swarmModeDisabledReason={ - activeId && pendingOrchestrationModeBySession[activeId] === true - ? shellCopy.swarmModeChanging + sessionMode={activeSessionMode} + sessionModePending={activeId + ? pendingCollaborationModeBySession[activeId] === true + || pendingOrchestrationModeBySession[activeId] === true + : false} + sessionModeDisabledReason={ + activeId + && (pendingCollaborationModeBySession[activeId] === true + || pendingOrchestrationModeBySession[activeId] === true) + ? shellCopy.sessionModeChanging : activeStreamingLive - ? shellCopy.swarmModeStreaming + ? shellCopy.sessionModeStreaming : activeId && turnActive - ? shellCopy.swarmModeRunning + ? shellCopy.sessionModeRunning : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.swarmModeWaiting + ? shellCopy.sessionModeWaiting : undefined } - onSwarmModeChange={(active) => { - void setSwarmMode(active); - }} - graphModeActive={activeId - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' - : newChatGraphModeActive} - graphModePending={activeId ? pendingOrchestrationModeBySession[activeId] === true : false} - graphModeDisabledReason={ - activeId && pendingOrchestrationModeBySession[activeId] === true - ? shellCopy.graphModeChanging - : activeStreamingLive - ? shellCopy.graphModeStreaming - : activeId && turnActive - ? shellCopy.graphModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.graphModeWaiting - : undefined - } - onGraphModeChange={(active) => { - void setGraphMode(active); - }} + onSessionModeChange={setSessionMode} /> } diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 3329a6e36e..0a50d1e463 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -436,10 +436,11 @@ 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. */ + sessionModeChanging: string; + sessionModeStreaming: string; + sessionModeRunning: string; + sessionModeWaiting: string; planModeFailedTitle: string; planModeFallback: string; planModeExitPendingTitle: string; @@ -448,19 +449,11 @@ type ShellCopy = { 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; @@ -1145,10 +1138,10 @@ const SHELL_COPY_BY_LOCALE = { permissionModeStreaming: '当前任务正在流式输出,等结束后再切换权限模式。', permissionModeRunning: '当前任务正在运行,等结束后再切换权限模式。', permissionModeWaiting: '当前有工具调用正在等待确认,处理后再切换权限模式。', - planModeChanging: 'Plan Mode 正在切换,完成后再继续操作。', - planModeStreaming: '当前任务正在流式输出,等结束后再切换 Plan Mode。', - planModeRunning: '当前任务正在运行,等结束后再切换 Plan Mode。', - planModeWaiting: '当前有工具调用正在等待确认,处理后再切换 Plan Mode。', + sessionModeChanging: '会话模式正在切换,完成后再继续操作。', + sessionModeStreaming: '当前任务正在流式输出,等结束后再切换会话模式。', + sessionModeRunning: '当前任务正在运行,等结束后再切换会话模式。', + sessionModeWaiting: '当前有工具调用正在等待确认,处理后再切换会话模式。', planModeFailedTitle: '切换 Plan Mode 失败', planModeFallback: 'Plan Mode 暂时无法切换,请稍后重试。', planModeExitPendingTitle: '放弃当前方案?', @@ -1158,19 +1151,11 @@ 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 已开启', @@ -1681,10 +1666,10 @@ 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.', + sessionModeChanging: 'The session mode is changing. Wait for it to finish before continuing.', + sessionModeStreaming: 'This task is streaming. Wait for it to finish before changing the session mode.', + sessionModeRunning: 'This task is running. Wait for it to finish before changing the session mode.', + sessionModeWaiting: 'A tool call is waiting for confirmation. Respond before changing the session mode.', planModeFailedTitle: 'Could not change Plan Mode', planModeFallback: 'Plan Mode could not be changed. Try again later.', planModeExitPendingTitle: 'Abandon the current plan?', @@ -1694,19 +1679,11 @@ 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', 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..56c29512c8 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -168,6 +168,24 @@ 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 circles ahead of the labels. A menu radio item draws the + circle instead, so composer.tsx passes the check through `endContent` and + the circle is suppressed here. + + `astryx-radio-indicator` is RadioIndicator's published theme target (see its + themeProps call), not an internal class. Scoped to the + panel alone: the + permission menu beside it keeps its radio circles, and nothing outside these + two menus is touched. + + Drop this rule and the `endContent` mark together once a radio item can + choose its own indicator upstream. */ +.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..306abb61f8 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -225,15 +225,11 @@ 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 choice, 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, + sessionMode: 'default', + onSessionModeChange: noop, // Thinking is a separate right-footer Selector when levels are offered. activeThinkingLevels: ['off', 'low', 'medium', 'high', 'xhigh'], activeThinkingLevel: 'medium', @@ -996,23 +992,14 @@ export const TitlebarIdentityTruncated: Story = { // footer controls rather than as staged context in the drawer (#1897). It // trails the model + thinking pair so switching it never shifts those two. export const PlanModeOn: Story = { - render: () => , + render: () => , }; // Real path: the same for the orchestration side. All marks share the one // 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: () => , -}; - -// 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. -export const PlanAndSwarmModeOn: Story = { - render: () => ( - - ), + render: () => , }; // Real path: a mode is on AND context is staged for the next send. The point of @@ -1022,7 +1009,7 @@ export const ModeOnWithPendingAttachments: Story = { render: () => ( ; /** - * Session collaboration mode switch. Agent mode is the implicit default, - * so the composer only exposes whether Plan mode is enabled. + * The Session's mode — one value, four options. + * + * Two persisted fields project onto it: `collaborationMode` + * ('agent' | 'plan') and `orchestrationMode` ('default' | 'swarm' | + * 'graph'). They were three independent switches here, which offered + * combinations the runtime cannot honour — Plan strips subagent-category + * tools and the agent-graph tools (`plan-mode.ts`), and those are what + * Swarm and Graph are made of. Turning Swarm on also silently turned + * Graph off, because both write the same field. + * + * So the control offers what is actually selectable, and the host owns + * writing both fields for the choice it receives — the composer never + * sequences two Session mutations of its own. */ - 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; + sessionMode?: ComposerSessionMode; + sessionModePending?: boolean; + sessionModeDisabledReason?: string; + onSessionModeChange?(mode: ComposerSessionMode): void | Promise; /** * Composer mention popups. Both are optional and the whole feature no-ops * when absent (SSR contracts render Composer with minimal props): @@ -1284,74 +1299,75 @@ 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 Session's mode, in the order the + menu lists it. `default` leads + * because it is the way out of the other three, and a one-of-N list without + * its neutral option cannot express "none of these". + * + * The mark at the tail of the footer's left controls is the resting readout + * for a non-default mode, plus one nearby way out; the menu stays the + * switch. It sits after the model and thinking pickers, so changing mode + * never shifts those two. * * 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. */ - const modes: ReadonlyArray<{ - id: 'plan' | 'swarm' | 'graph'; - active: boolean; + const sessionModes: ReadonlyArray<{ + id: ComposerSessionMode; icon: ReactNode; label: string; onTitle: string; - isDisabled: boolean; - disabledReason: string | undefined; - onDeactivate(): void; }> = [ + { + id: 'default', + icon: