From d459edc69d693e15b6e477bca225317456c5736a Mon Sep 17 00:00:00 2001 From: YayoiNanoka Date: Sat, 17 Oct 2026 16:21:11 +0800 Subject: [PATCH] refactor(desktop): move session shell rules into feature owners Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 5 +- .../session-navigation-controller.test.ts | 14 +- apps/desktop/src/renderer/app-shell.tsx | 136 +++--------------- .../controller/composer-surface-owner.ts | 57 ++++++++ .../renderer/features/conversation/index.ts | 2 + .../use-session-navigation-reads.ts | 15 +- .../controller/session-mode-commands.ts | 84 +++++++++++ .../features/session-settings/index.ts | 2 + 8 files changed, 188 insertions(+), 127 deletions(-) create mode 100644 apps/desktop/src/renderer/features/conversation/controller/composer-surface-owner.ts create mode 100644 apps/desktop/src/renderer/features/session-settings/controller/session-mode-commands.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 606f9de0138..05bb8436ebf 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -354,6 +354,7 @@ "TaskReadinessProvider", "createComposerStagingCommands", "createComposerSubmissionCommands", + "createComposerSurfaceOwner", "createExecutorSessionActivator", "resolveTaskReadinessModelTarget", "useActiveExecutionBoundary", @@ -467,6 +468,8 @@ "src/renderer/features/session-settings/index.ts": { "appShell": [ "SessionSettingsProvider", + "createSessionModeCommands", + "deriveSessionSettingsPresentation", "useSessionSettingIntent" ], "composition": [ @@ -743,7 +746,7 @@ "react": 1 }, "importSpecifiers": 43, - "nonTriviaTokens": 7276 + "nonTriviaTokens": 6843 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index ba3b36f3746..4170bab6f99 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -537,12 +537,14 @@ describe('useSessionNavigationReads', () => { const { root } = installReactRenderer(); const catalog = createSessionCatalogController(); catalog.commitSessions(linkedCatalog); + const opened: string[] = []; + const openSession = (sessionId: string) => { opened.push(sessionId); }; await act(async () => root.render( navigationTree( catalog, { activeSessionId: 'child', workHubActive: false }, - createElement(ReadsProbe, { catalog, activeSessionId: 'child' }), + createElement(ReadsProbe, { catalog, activeSessionId: 'child', openSession }), createElement(RailProbe), ), ), @@ -555,19 +557,23 @@ describe('useSessionNavigationReads', () => { ['root', 'remote', 'environment'], ); assert.equal(latestRail.activeId, 'root'); - assert.equal(latestReads.activeParentSession?.id, 'root'); + assert.equal(latestReads.titlebarParentSession?.name, linkedCatalog.find(row => row.id === 'root')?.name); + latestReads.titlebarParentSession?.onOpen(); + assert.equal(opened.at(-1), 'root'); await act(async () => root.render( navigationTree( catalog, { activeSessionId: 'side-conversation', workHubActive: false }, - createElement(ReadsProbe, { catalog, activeSessionId: 'side-conversation' }), + createElement(ReadsProbe, { catalog, activeSessionId: 'side-conversation', openSession }), null, ), ), ); - assert.equal(latestReads.activeParentSession?.id, 'root'); + assert.equal(latestReads.titlebarParentSession?.name, linkedCatalog.find(row => row.id === 'root')?.name); + latestReads.titlebarParentSession?.onOpen(); + assert.equal(opened.at(-1), 'root'); }); }); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ae26c7714b7..d2ba5348d66 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -32,7 +32,6 @@ import { type SetStateAction, } from 'react'; import type { QuoteRef } from '@maka/core/events'; -import type { OrchestrationMode } from '@maka/core/orchestration'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; import { collapseSessionRevisions } from '@maka/core/session-revisions'; import { isLinkedSubagentSession } from '@maka/core/session'; @@ -97,7 +96,7 @@ import * as Diagnostics from './features/diagnostics/index.js'; import { getDesktopConversationCopy } from './application/contracts/conversation-copy'; import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; -import { SessionSettingsProvider, useSessionSettingIntent } from './features/session-settings'; +import { SessionSettingsProvider, useSessionSettingIntent, createSessionModeCommands, deriveSessionSettingsPresentation } from './features/session-settings'; import { pendingSessionView } from './pending-session-view'; import { readScrollMotionBehavior } from './scroll-motion-policy'; import { readNavigationState, selectNavigation } from './nav-selection'; @@ -136,12 +135,6 @@ import { useShellConnections } from './use-shell-connections'; import { useSystemUiLocale } from './use-system-ui-locale'; import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell'; -type ComposerImportOwner = { - sessionId: string | undefined; - navSection: NavSelection['section']; - newTaskDraftKey?: string; -}; - export function AppShell() { const [uiLocalePreference, setUiLocalePreference] = useState('auto'); const [uiLocaleOverride, setUiLocaleOverride] = useState(null); @@ -423,20 +416,8 @@ function AppShellContent({ const composerEditing = queueSurface.editing; const rendererMountedRef = useRef(true); const { setPermissionMode, setSessionModel, setSessionThinkingLevel, setSessionExecutor } = sessionSettingIntent.commands; - const modelConfigurationOverlay = sessionSettingIntent.overlay.modelConfiguration; - const activeSessionForModelControls = activeSession - ? { - ...activeSession, - ...(modelConfigurationOverlay - ? { - llmConnectionId: modelConfigurationOverlay.modelTarget.llmConnectionId, - llmConnectionSlug: modelConfigurationOverlay.modelTarget.llmConnectionSlug, - model: modelConfigurationOverlay.modelTarget.model, - thinkingLevel: modelConfigurationOverlay.thinkingLevel ?? undefined, - } - : {}), - } - : undefined; + const { activeSessionForModelControls, activePlanMode, activeOrchestrationMode } = + deriveSessionSettingsPresentation(activeSession, activeId, sessionSettingIntent); // Surface a credential-lifecycle alert directly in the chat header when // the active session's connection is in `needs_reauth` / `error` or has // been deleted entirely with no usable default. Main resolves credential @@ -517,43 +498,12 @@ function AppShellContent({ 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). - function setPlanMode(active: boolean): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) { - sessionSettingIntent.commands.setNewTaskPlanMode(active); - return Promise.resolve(true); - } - if (active === activePlanMode) return Promise.resolve(true); - return sessionSettingIntent.commands.setPlanMode(sessionId, active); - } - - /** - * 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) { - sessionSettingIntent.commands.setNewTaskOrchestrationMode(mode); - return Promise.resolve(true); - } - if (mode === activeOrchestrationMode) return Promise.resolve(true); - return sessionSettingIntent.commands.setOrchestrationMode(sessionId, mode); - } - - function setOrchestrationModeActive( - mode: Exclude, - active: boolean, - ): Promise { - if (active) return setOrchestrationMode(mode); - if (activeOrchestrationMode !== mode) return Promise.resolve(true); - return setOrchestrationMode('default'); - } + const { setPlanMode, setOrchestrationMode, setOrchestrationModeActive } = createSessionModeCommands({ + sessionId: () => activeIdRef.current, + planMode: () => activePlanMode, + orchestrationMode: () => activeOrchestrationMode, + commands: sessionSettingIntent.commands, + }); const openSessionInChatRef = useRef< (sessionId: string, turnId?: string, sequence?: number) => void @@ -598,18 +548,6 @@ function AppShellContent({ permissionMode: newSessionPermissionMode, }) : 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 - ? sessionSettingIntent.overlay.planMode - ?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan') - : newTaskSettings.planMode; - const activeOrchestrationMode: OrchestrationMode = activeId - ? sessionSettingIntent.overlay.orchestrationMode - ?? activeSessionForView?.orchestrationMode - ?? 'default' - : newTaskSettings.orchestrationMode; const { boundary: activeExecutionBoundary, unreadable: activeExecutionBoundaryUnreadable, @@ -809,20 +747,14 @@ function AppShellContent({ }; const { revisionNavigation, - activeParentSession, + titlebarParentSession, layout: { collapsed: sessionListCollapsed, width: sessionListWidth }, } = useSessionNavigationReads({ catalog: sessionCatalogController, activeSessionId: activeId, + openSession: openSessionInChat, }); const sessionSideNavHandleRef = sessionRailLayoutStore.collapseHandleRef; - const titlebarParentSession = useMemo(() => { - if (!activeParentSession) return undefined; - return { - name: activeParentSession.name, - onOpen: () => openSessionInChatRef.current(activeParentSession.id), - }; - }, [activeParentSession]); const archivedTasksBridge = useMemo( () => ({ catalog: sessionCatalogController, @@ -884,44 +816,14 @@ function AppShellContent({ themePalette, themePref, }); - function captureComposerImportOwner(): ComposerImportOwner { - return { - sessionId: activeIdRef.current, - navSection: navSelectionRef.current.section, - ...(activeIdRef.current === undefined - ? { newTaskDraftKey: currentNewTaskDraftKey } - : {}), - }; - } - - /** - * "Is this owner still the surface the user is looking at." One rule, both - * halves: an async result that lands after the user moved on must not toast, - * navigate or steal focus, and `selectNavigation` never clears `activeId` - * (nav-selection.ts) — so the session id alone answers yes long after the - * user left for 扩展 or 设置. - * - * The two below are this same question with a precondition on what KIND of - * owner the caller wants, not second opinions about the question. They were - * three independent spellings once, and the one that re-derived it from an - * id drifted: it lost the section half, which is exactly what let a failed - * send pull a user out of 技能 and into 设置 · 模型. - */ - function isShellSurfaceOwnerActive(owner: ComposerImportOwner): boolean { - return navSelectionRef.current.section === owner.navSection && - isSessionSelected(owner.sessionId) && - (owner.sessionId !== undefined || owner.newTaskDraftKey === currentNewTaskDraftKey); - } - - /** …and the owner was captured on the chat surface. */ - function isComposerImportOwnerActive(owner: ComposerImportOwner): boolean { - return owner.navSection === 'sessions' && isShellSurfaceOwnerActive(owner); - } - - /** …and it was the new-chat surface, which by definition has no session. */ - function isNewChatSendSurfaceActive(owner: ComposerImportOwner): boolean { - return owner.sessionId === undefined && isComposerImportOwnerActive(owner); - } + const { + captureComposerImportOwner, + isShellSurfaceOwnerActive, + isComposerImportOwnerActive, + isNewChatSendSurfaceActive, + } = Conversation.createComposerSurfaceOwner({ + activeIdRef, navSelectionRef, newTaskDraftKey: currentNewTaskDraftKey, isSessionSelected, + }); async function bootstrapSessions() { const next = await refreshSessions(); diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-surface-owner.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-surface-owner.ts new file mode 100644 index 00000000000..e2c695f8d77 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-surface-owner.ts @@ -0,0 +1,57 @@ +/* + * 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 { NavSelection } from '@maka/ui'; + +export interface ComposerSurfaceOwner { + sessionId: string | undefined; + navSection: NavSelection['section']; + newTaskDraftKey?: string; +} + +/** + * Async composer work must still belong to the visible session or new-task draft. + * Navigation away from chat keeps the selected session, so the section matters too. + */ +export function createComposerSurfaceOwner(input: { + activeIdRef: { current: string | undefined }; + navSelectionRef: { current: NavSelection }; + newTaskDraftKey?: string; + isSessionSelected(sessionId: string | undefined): boolean; +}) { + const captureComposerImportOwner = (): ComposerSurfaceOwner => ({ + sessionId: input.activeIdRef.current, + navSection: input.navSelectionRef.current.section, + ...(input.activeIdRef.current === undefined ? { newTaskDraftKey: input.newTaskDraftKey } : {}), + }); + const isShellSurfaceOwnerActive = (owner: ComposerSurfaceOwner) => + input.navSelectionRef.current.section === owner.navSection && + input.isSessionSelected(owner.sessionId) && + (owner.sessionId !== undefined || owner.newTaskDraftKey === input.newTaskDraftKey); + const isComposerImportOwnerActive = (owner: ComposerSurfaceOwner) => + owner.navSection === 'sessions' && isShellSurfaceOwnerActive(owner); + const isNewChatSendSurfaceActive = (owner: ComposerSurfaceOwner) => + owner.sessionId === undefined && isComposerImportOwnerActive(owner); + return { + captureComposerImportOwner, + isShellSurfaceOwnerActive, + isComposerImportOwnerActive, + isNewChatSendSurfaceActive, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 24c93e9712e..3f15897ef1e 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -80,3 +80,5 @@ export { TaskReadinessProvider, TaskReadinessNoticeConsumer } from './ui/task-re export { createComposerSubmissionCommands } from './controller/composer-submission-commands.js'; export { ComposerSubmissionServicesProvider, type ComposerSubmissionServices } from './submission-services.js'; export { ComposerSubmissionProvider } from './ui/composer-submission-provider.js'; + +export { createComposerSurfaceOwner } from './controller/composer-surface-owner.js'; diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts index a2dd5a161c4..fc22476e40b 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useLayoutEffect } from 'react'; +import { useLayoutEffect, useMemo } from 'react'; import { useMediaQuery } from '@astryxdesign/core/hooks'; import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; import type { SessionCatalogState } from '../../../application/contracts/session-catalog/session-catalog-state.js'; @@ -37,8 +37,8 @@ import type { SessionNavigationSession } from '../ports.js'; export interface SessionNavigationReads { revisionNavigation: SessionRevisionNavigation | undefined; - /** The active Session's parent row, for the titlebar breadcrumb. */ - activeParentSession: SessionNavigationSession | undefined; + /** The active Session's parent breadcrumb and navigation action. */ + titlebarParentSession: { name: string; onOpen(): void } | undefined; layout: SessionRailLayoutState; } @@ -87,8 +87,9 @@ function revisionNavigationsEqual( export function useSessionNavigationReads(input: { catalog: SessionCatalogController; activeSessionId: string | undefined; + openSession(sessionId: string): void; }): SessionNavigationReads { - const { activeSessionId, catalog } = input; + const { activeSessionId, catalog, openSession } = input; const revisionNavigation = useExternalStoreSelector( catalog, selectRevisionNavigation, @@ -100,6 +101,10 @@ export function useSessionNavigationReads(input: { selectActiveParentSession, activeSessionId, ); + const titlebarParentSession = useMemo(() => activeParentSession ? { + name: activeParentSession.name, + onOpen: () => openSession(activeParentSession.id), + } : undefined, [activeParentSession, openSession]); /* The one place the rail asks how wide the window is. The store is module-scoped, so it is told rather than handed a prop; a layout effect re-renders before paint, so a narrow launch never shows it. */ @@ -108,5 +113,5 @@ export function useSessionNavigationReads(input: { sessionRailLayoutStore.setCompact(sidebarCompact); }, [sidebarCompact]); const layout = useExternalStoreSelector(sessionRailLayoutStore, selectRailLayout); - return { revisionNavigation, activeParentSession, layout }; + return { revisionNavigation, titlebarParentSession, layout }; } diff --git a/apps/desktop/src/renderer/features/session-settings/controller/session-mode-commands.ts b/apps/desktop/src/renderer/features/session-settings/controller/session-mode-commands.ts new file mode 100644 index 00000000000..f06a4839640 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-settings/controller/session-mode-commands.ts @@ -0,0 +1,84 @@ +/* + * 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 { SessionSummary } from '@maka/core/session'; +import type { NewTaskSettings, SessionSettingValues, SessionSettingsCommands } from '../model/session-settings-contract.js'; +import type { OrchestrationMode } from '@maka/core/orchestration'; + +/** The composer menu and slash commands share the same selected/new task rules. */ +export function createSessionModeCommands(input: { + sessionId(): string | undefined; + planMode(): boolean; + orchestrationMode(): OrchestrationMode; + commands: SessionSettingsCommands; +}) { + const setPlanMode = (active: boolean): Promise => { + const sessionId = input.sessionId(); + if (!sessionId) { + input.commands.setNewTaskPlanMode(active); + return Promise.resolve(true); + } + if (active === input.planMode()) return Promise.resolve(true); + return input.commands.setPlanMode(sessionId, active); + }; + const setOrchestrationMode = (mode: OrchestrationMode): Promise => { + const sessionId = input.sessionId(); + if (!sessionId) { + input.commands.setNewTaskOrchestrationMode(mode); + return Promise.resolve(true); + } + if (mode === input.orchestrationMode()) return Promise.resolve(true); + return input.commands.setOrchestrationMode(sessionId, mode); + }; + // Turning off Swarm must leave a Session already in Graph unchanged. + const setOrchestrationModeActive = ( + mode: Exclude, active: boolean, + ): Promise => { + if (active) return setOrchestrationMode(mode); + if (input.orchestrationMode() !== mode) return Promise.resolve(true); + return setOrchestrationMode('default'); + }; + return { setPlanMode, setOrchestrationMode, setOrchestrationModeActive }; +} + +/** Each control keeps its own optimistic value, including before the summary loads. */ +export function deriveSessionSettingsPresentation( + session: T | undefined, + sessionId: string | undefined, + intent: { overlay: Partial; newTask: NewTaskSettings }, +) { + const model = intent.overlay.modelConfiguration; + return { + activeSessionForModelControls: session && { + ...session, + ...(model ? { + llmConnectionId: model.modelTarget.llmConnectionId, + llmConnectionSlug: model.modelTarget.llmConnectionSlug, + model: model.modelTarget.model, + thinkingLevel: model.thinkingLevel ?? undefined, + } : {}), + }, + activePlanMode: sessionId + ? intent.overlay.planMode ?? ((session?.collaborationMode ?? 'agent') === 'plan') + : intent.newTask.planMode, + activeOrchestrationMode: sessionId + ? intent.overlay.orchestrationMode ?? session?.orchestrationMode ?? 'default' + : intent.newTask.orchestrationMode, + }; +} diff --git a/apps/desktop/src/renderer/features/session-settings/index.ts b/apps/desktop/src/renderer/features/session-settings/index.ts index 820f7b58133..08dbc470c0f 100644 --- a/apps/desktop/src/renderer/features/session-settings/index.ts +++ b/apps/desktop/src/renderer/features/session-settings/index.ts @@ -21,3 +21,5 @@ export { SessionSettingsServicesProvider } from './services-context.js'; export { useSessionSettingIntent } from './ui/use-session-setting-intent.js'; export type { SessionSettingsServices } from './ports.js'; export { SessionSettingsProvider } from './ui/session-settings-provider.js'; + +export { createSessionModeCommands, deriveSessionSettingsPresentation } from './controller/session-mode-commands.js';