From d3eb37be759bb7458827f5de661b04963db47969 Mon Sep 17 00:00:00 2001 From: testikun Date: Thu, 3 Sep 2026 15:56:05 +0800 Subject: [PATCH 1/7] feat(desktop): preserve Side Conversations across linked sessions Generated-by: OpenAI Codex --- .../__tests__/quote-companion-retry.test.ts | 2 + .../main/__tests__/workbar-controller.test.ts | 145 +++++++++++++++++- apps/desktop/src/renderer/app-shell.tsx | 3 +- .../src/renderer/features/workbar/README.md | 4 +- .../controller/use-workbar-controller.ts | 46 ++++-- .../tools/side-chat/quote-companion-panel.tsx | 2 + .../side-conversation-session-family.ts | 89 +++++++++++ .../tools/side-chat/use-quote-companion.ts | 11 +- .../features/workbar/ui/workbar-host.tsx | 5 +- .../features/workbar/ui/workbar-surface.tsx | 10 +- 10 files changed, 295 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index cb7dbce1e9..98ca43b23c 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -1919,6 +1919,7 @@ function QuoteCompanionProbe(props: { const sourceSession = props.sourceSession ?? SOURCE_SESSION; const companion = useQuoteCompanion({ panelId: 'retry-panel', + sourceSessionId: sourceSession.id, pendingQuotes: [], sourceSession, modelChoices: props.modelChoices ?? [choiceFor(sourceSession)], @@ -1947,6 +1948,7 @@ function QuoteCompanionOwnershipProbe(props: { const sourceSession = props.sourceSession ?? SOURCE_SESSION; const companion = useQuoteCompanion({ panelId: 'ownership-panel', + sourceSessionId: sourceSession.id, pendingQuotes: props.pendingQuotes ?? [], sourceSession, modelChoices: props.modelChoices ?? [choiceFor(sourceSession)], diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index bde0503b47..6c87b186d9 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -112,13 +112,16 @@ function controller(): WorkbarController { function input( activeSession: SessionSummary | undefined, errors: string[] = [], + sessionCatalog?: readonly SessionSummary[], ): UseWorkbarControllerInput { + const sessions = sessionCatalog ?? (activeSession ? [activeSession] : []); return { available: true, activeSession, + sessions, projectId: activeSession?.projectId, projectAliases: [], - authoritativeSessionIds: new Set(activeSession ? [activeSession.id] : []), + authoritativeSessionIds: new Set(sessions.map((session) => session.id)), shellObscured: false, modelChoices: [], reportError: (title, description) => errors.push(`${title}: ${description}`), @@ -503,6 +506,146 @@ describe('useWorkbarController', () => { ); }); + it('preserves Side Chat across linked child navigation and restores its tabs', async () => { + const { root } = installReactRenderer(); + const parent = session('parent'); + const child = session('child'); + child.subagent = { parentSessionId: parent.id }; + const services = createFakeWorkbarServices(); + const sessions = [parent, child]; + + await act(async () => renderController(root, services, input(parent, [], sessions))); + await act(async () => controller().commands.openTool('side-chat')); + const panel = controller().host.quotes?.[0]; + assert.ok(panel); + const tab = controller().host.panelsState.right.tabs.find( + (candidate) => candidate.id === `side-chat:${panel.id}`, + ); + assert.ok(tab); + await act(async () => controller().host.onActivityStateChange?.(panel.id, true)); + + await act(async () => renderController(root, services, input(child, [], sessions))); + + assert.equal(controller().host.surfaceKey, parent.id); + assert.equal(controller().host.quotes?.[0], panel); + assert.equal( + controller().host.panelsState.right.tabs.some( + (candidate) => candidate.id === tab.id, + ), + true, + ); + assert.equal(controller().host.activeSideChatPanelIds?.has(panel.id), true); + + await act(async () => renderController(root, services, input(parent, [], sessions))); + assert.equal(controller().host.surfaceKey, parent.id); + assert.equal(controller().host.quotes?.[0], panel); + assert.equal( + controller().host.panelsState.right.tabs.some( + (candidate) => candidate.id === tab.id, + ), + true, + ); + }); + + it('keeps Side Chat while the active source awaits its catalog row', async () => { + const { root } = installReactRenderer(); + const source = session('pending-source'); + const services = createFakeWorkbarServices(); + + await act(async () => renderController(root, services, input(source, [], []))); + await act(async () => controller().commands.openTool('side-chat')); + const panelId = controller().host.quotes?.[0]?.id; + assert.ok(panelId); + + await act(async () => renderController(root, services, input(source, [], []))); + assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true); + assert.equal( + controller().host.panelsState.right.tabs.some( + (candidate) => candidate.id === `side-chat:${panelId}`, + ), + true, + ); + }); + + it('keeps the family surface mounted when one of multiple source panels closes', async () => { + const { root } = installReactRenderer(); + const parent = session('parent'); + const child = session('child'); + child.subagent = { parentSessionId: parent.id }; + const sessions = [parent, child]; + const services = createFakeWorkbarServices(); + + await act(async () => renderController(root, services, input(parent, [], sessions))); + await act(async () => controller().commands.openTool('side-chat')); + const parentPanelId = controller().host.quotes?.[0]?.id; + assert.ok(parentPanelId); + + await act(async () => renderController(root, services, input(child, [], sessions))); + await act(async () => controller().commands.openTool('side-chat')); + const childPanel = controller().host.quotes?.find( + (panel) => panel.sourceSessionId === child.id, + ); + assert.ok(childPanel); + assert.equal(controller().host.surfaceKey, parent.id); + + const parentTab = controller().host.panelsState.right.tabs.find( + (tab) => tab.id === `side-chat:${parentPanelId}`, + ); + assert.ok(parentTab); + await act(async () => controller().host.onCloseTab('right', parentTab)); + + assert.equal(controller().host.surfaceKey, parent.id); + assert.equal( + controller().host.quotes?.some((panel) => panel.id === childPanel.id), + true, + ); + }); + + it('cleans Side Chat when its source Session is removed', async () => { + const { root } = installReactRenderer(); + const parent = session('parent'); + const child = session('child'); + child.subagent = { parentSessionId: parent.id }; + const services = createFakeWorkbarServices(); + + await act(async () => renderController(root, services, input(parent, [], [parent, child]))); + await act(async () => controller().commands.openTool('side-chat')); + const panelId = controller().host.quotes?.[0]?.id; + assert.ok(panelId); + + await act(async () => renderController(root, services, input(child, [], [child]))); + assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), false); + assert.equal( + controller().host.panelsState.right.tabs.some( + (candidate) => candidate.id === `side-chat:${panelId}`, + ), + false, + ); + }); + + it('cleans a child-owned Side Chat when navigating back to its parent', async () => { + const { root } = installReactRenderer(); + const parent = session('parent'); + const child = session('child'); + child.subagent = { parentSessionId: parent.id }; + const sessions = [parent, child]; + const services = createFakeWorkbarServices(); + + await act(async () => renderController(root, services, input(child, [], sessions))); + await act(async () => controller().commands.openTool('side-chat')); + const panelId = controller().host.quotes?.[0]?.id; + assert.ok(panelId); + + await act(async () => renderController(root, services, input(parent, [], sessions))); + assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), false); + assert.equal( + controller().host.panelsState.right.tabs.some( + (candidate) => candidate.id === `side-chat:${panelId}`, + ), + false, + ); + }); + it('hides created companion Sessions until cleanup or reconciliation', async () => { const { root } = installReactRenderer(); const services = createFakeWorkbarServices(); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 78b311a731..3ef52e83ba 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1603,9 +1603,10 @@ function AppShellContent({ const workbar = useWorkbarController({ available: workbarAvailable, activeSession: activeSessionForView, + sessions, projectId: currentProjectId, projectAliases: currentProject?.aliases ?? [], - authoritativeSessionIds: authoritativeSessionIds ?? undefined, + authoritativeSessionIds, shellObscured, modelChoices: chatModelChoices, reportError: reportWorkbarError, diff --git a/apps/desktop/src/renderer/features/workbar/README.md b/apps/desktop/src/renderer/features/workbar/README.md index e8c6754ca2..153343fc79 100644 --- a/apps/desktop/src/renderer/features/workbar/README.md +++ b/apps/desktop/src/renderer/features/workbar/README.md @@ -65,8 +65,8 @@ remounted when the active session changes. - Terminal ownership is registered as soon as `start` returns, before the tab state commits. Host projection excludes resources owned by another Session, so a Session switch cannot briefly reattach an old Terminal. -- Side Chat survives panel collapse and is cleaned only when its tab closes or - when navigation leaves its source session. +- Side Chat survives panel collapse and navigation within its linked Session + family; it is cleaned when its tab closes or navigation leaves that family. - Disposed Side Chat operations are fenced at every fork/send boundary; a late fork is cleaned and a late send cannot write back into an abandoned panel. - Inactive tabs stay mounted; their hooks receive the existing active/hidden diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 59662743ab..a4e150f272 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -63,6 +63,10 @@ import { } from '../tools/side-chat/quote-companion-visibility.js'; import { recoverOrphanedCompanionCopies } from '../tools/side-chat/quote-companion-core.js'; import { useSideConversationWorkspace } from '../tools/side-chat/use-side-conversation-workspace.js'; +import { + isLinkedSideConversationSessionFamily, + linkedSideConversationFamilyRootId, +} from '../tools/side-chat/side-conversation-session-family.js'; import { useWorkbarLayoutState } from './use-workbar-layout-state.js'; interface OpenToolOptions { @@ -90,6 +94,7 @@ export interface UseWorkbarControllerInput { /** Whether the Session workspace (rather than a module page) owns the shell. */ available: boolean; activeSession: SessionSummary | undefined; + sessions: readonly SessionSummary[]; projectId: string | null | undefined; projectAliases: readonly string[]; authoritativeSessionIds: ReadonlySet | undefined; @@ -546,7 +551,12 @@ export function useWorkbarController( useLayoutEffect(() => { const stalePanels = sideConversations.panels.filter( - (panel) => panel.sourceSessionId !== activeSessionId, + (panel) => + !isLinkedSideConversationSessionFamily( + panel.sourceSessionId, + input.activeSession, + input.sessions, + ), ); if (stalePanels.length === 0) return; const staleIds = new Set(stalePanels.map((panel) => panel.id)); @@ -562,6 +572,8 @@ export function useWorkbarController( sideConversations.removePanels(staleIds); }, [ activeSessionId, + input.activeSession, + input.sessions, layout.closeWorkbarTab, layout.workbarPanelsState, sideConversations.panels, @@ -673,15 +685,28 @@ export function useWorkbarController( ], ); - const activeSideChatTabIds = useMemo( + const activeSideConversationPanels = useMemo( () => - new Set( - sideConversations.panels - .filter((panel) => panel.sourceSessionId === activeSessionId) - .map((panel) => `side-chat:${panel.id}`), + sideConversations.panels.filter((panel) => + isLinkedSideConversationSessionFamily( + panel.sourceSessionId, + input.activeSession, + input.sessions, + ), ), - [activeSessionId, sideConversations.panels], + [input.activeSession, input.sessions, sideConversations.panels], ); + const activeSideChatTabIds = useMemo( + () => new Set(activeSideConversationPanels.map((panel) => `side-chat:${panel.id}`)), + [activeSideConversationPanels], + ); + const sideConversationSurfaceKey = useMemo(() => { + if (activeSideConversationPanels.length === 0) return activeSessionId; + return ( + linkedSideConversationFamilyRootId(input.activeSession, input.sessions) ?? + activeSessionId + ); + }, [activeSessionId, activeSideConversationPanels.length, input.activeSession, input.sessions]); const hostPanelsState = useMemo( () => projectWorkbarPanelsForSession( @@ -691,7 +716,6 @@ export function useWorkbarController( ), [activeSessionId, activeSideChatTabIds, layout.workbarPanelsState], ); - return { commands, selectors: { @@ -708,6 +732,7 @@ export function useWorkbarController( rightWidth: layout.workbarWidth, bottomHeight: layout.bottomPanelHeight, panelsState: hostPanelsState, + surfaceKey: sideConversationSurfaceKey, onActivateTab: layout.activateWorkbarTab, onCloseTab: closeTab, onCloseTabs: closeTabs, @@ -726,9 +751,8 @@ export function useWorkbarController( }, rightResizable: layout.workbarResizable, bottomResizable: layout.bottomPanelResizable, - quotes: sideConversations.panels.filter( - (panel) => panel.sourceSessionId === activeSessionId, - ), + quotes: activeSideConversationPanels, + sessions: input.sessions, onQuotesConsumed: (snapshot) => sideConversations.updatePanel(snapshot.panelId, (panel) => consumeCompanionQuoteSnapshot(panel, snapshot) ?? panel, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index fe04f827d9..ad5a9caa28 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -71,6 +71,7 @@ import { useWorkbarServices } from '../../services-context.js'; */ export function QuoteCompanionPanel(props: { panelId: string; + sourceSessionId: string; active: boolean; /** Excerpts staged for the next send (accumulated as the user adds more). */ quotes: readonly StagedCompanionQuote[]; @@ -124,6 +125,7 @@ export function QuoteCompanionPanel(props: { }); const companion = useQuoteCompanion({ panelId: props.panelId, + sourceSessionId: props.sourceSessionId, pendingQuotes: props.quotes, sourceSession: props.sourceSession, modelChoices: props.modelChoices, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts new file mode 100644 index 0000000000..0941021393 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts @@ -0,0 +1,89 @@ +/* + * 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 { + linkedSubagentParentSessionId, + type SessionSummary, +} from '@maka/core/session'; + +type LinkedSession = Pick; + +/** + * Whether the active Session is the source itself or a linked descendant of + * the source. Ordinary branches deliberately do not participate: + * their `parentSessionId` is a different lineage concept. + */ +export function isLinkedSideConversationSessionFamily( + sourceSessionId: string, + activeSession: LinkedSession | undefined, + sessions: readonly LinkedSession[], +): boolean { + if (!activeSession) return false; + // The active source may be represented by the shell's pending Session view + // before its catalog row arrives. Keep the panel through that refresh; a + // missing source is only destructive once navigation has left its id. + if (sourceSessionId === activeSession.id) return true; + const sourceSession = sessions.find((session) => session.id === sourceSessionId); + if (!sourceSession) return false; + + const sessionsById = new Map(sessions.map((session) => [session.id, session])); + return reachesSession(activeSession, sourceSessionId, sessionsById); +} + +/** + * Stable key for a linked Session family. Using the active Session's root, + * rather than whichever panel happens to be listed first, keeps the mounted + * Workbar surface stable when one of several retained panels is closed. + */ +export function linkedSideConversationFamilyRootId( + activeSession: LinkedSession | undefined, + sessions: readonly LinkedSession[], +): string | undefined { + if (!activeSession) return undefined; + const sessionsById = new Map(sessions.map((session) => [session.id, session])); + const visited = new Set(); + let current = activeSession; + while (!visited.has(current.id)) { + visited.add(current.id); + const parentSessionId = linkedSubagentParentSessionId(current); + if (!parentSessionId) return current.id; + const parent = sessionsById.get(parentSessionId); + if (!parent) return current.id; + current = parent; + } + return current.id; +} + +function reachesSession( + start: LinkedSession, + targetSessionId: string, + sessionsById: ReadonlyMap, +): boolean { + const visited = new Set(); + let current: LinkedSession | undefined = start; + while (current) { + if (current.id === targetSessionId) return true; + if (visited.has(current.id)) return false; + visited.add(current.id); + const parentSessionId = linkedSubagentParentSessionId(current); + if (!parentSessionId) return false; + current = sessionsById.get(parentSessionId); + } + return false; +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index caff9d826f..5d4e838f41 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -115,6 +115,8 @@ function admissionOutcomeForMessage( export interface UseQuoteCompanionInput { /** Stable owner for the currently mounted panel generation. */ panelId: string; + /** Immutable source id used for cleanup even if the source leaves the catalog. */ + sourceSessionId: string; /** Excerpts staged for the next send; accumulates as the user adds more from * the main transcript. Attached to the next turn, then cleared by the host. */ pendingQuotes: readonly StagedCompanionQuote[]; @@ -210,13 +212,14 @@ function requiredAssistantMessageId(projection: LiveTurnProjection | undefined): * inherited history is hidden from the side transcript. The subscription is * established the moment the fork commits — before the run starts — so no * prompt/complete is missed. Reset only by unmount (tab close or switching away - * from the owning source session), which removes the ephemeral fork. Workbar + * from the owning source Session family), which removes the ephemeral fork. Workbar * collapse and New Tab navigation keep the panel mounted. */ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompanionResult { const { sideChat } = useWorkbarServices(); const { panelId, + sourceSessionId, locale, sourceSession, modelChoices, @@ -241,9 +244,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const confirmBypassRef = useRef(input.confirmBypass); confirmBypassRef.current = input.confirmBypass; const sourceModelReady = sessionHasExactModelChoice(sourceSession, modelChoices); - const sourceSessionId = sourceSession?.id; - const sourceSessionIdRef = useRef(sourceSession?.id); - sourceSessionIdRef.current = sourceSessionId; + const sourceSessionIdRef = useRef(sourceSessionId); const forkSetupPromiseRef = useRef | null>(null); const stopRequestRef = useRef | null>(null); const activeTurnIdRef = useRef(null); @@ -732,7 +733,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan sessionHasExactModelChoice(companion, modelChoices); // The fork is ephemeral (用完即弃): when the panel is dismissed — 退出, - // switching source session — unsubscribe and remove the fork so it never + // leaving the linked source Session family — unsubscribe and remove the fork so it never // lingers in the session list. Collapsing keeps the panel mounted and alive. useEffect(() => { const shouldDismiss = dismissalGuardRef.current.beginMount(); diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx index d0cf65016e..7373a87c70 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx @@ -88,6 +88,7 @@ export interface WorkbarHostModel { rightWidth: number; bottomHeight: number; panelsState: SessionWorkbarPanelsState; + surfaceKey?: string; onActivateTab: (placement: SessionWorkbarPlacement, tabId: string) => void; onCloseTab: (placement: SessionWorkbarPlacement, tab: SessionWorkbarTab) => void; onCloseTabs: ( @@ -115,6 +116,7 @@ export interface WorkbarHostModel { rightResizable: ResizableProps; bottomResizable: ResizableProps; quotes?: readonly QuoteCompanionPanelState[]; + sessions?: readonly SessionSummary[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; onRemoveQuote?: (target: CompanionQuoteTarget) => void; onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void; @@ -179,7 +181,7 @@ export function WorkbarHost({ model: props }: { model: WorkbarHostModel }) { } > void; quotes?: readonly QuoteCompanionPanelState[]; + sessions?: readonly SessionSummary[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; onRemoveQuote?: (target: CompanionQuoteTarget) => void; onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void; @@ -846,13 +847,20 @@ export function WorkbarSurface(props: { const panelId = tab.id.slice('side-chat:'.length); const quote = props.quotes?.find((candidate) => candidate.id === panelId); if (quote) { + const sourceSession = props.sessions?.find( + (session) => session.id === quote.sourceSessionId, + ) ?? + (props.sourceSession?.id === quote.sourceSessionId + ? props.sourceSession + : undefined); content = ( {})} From 0ced087c41d8a3483d7bb4fb4c23c9476efcc5cb Mon Sep 17 00:00:00 2001 From: testikun Date: Thu, 3 Sep 2026 22:33:24 +0800 Subject: [PATCH 2/7] feat(desktop): preserve Side Conversations across linked sessions Generated-by: OpenAI Codex --- .../src/renderer/features/workbar/README.md | 10 ++++++---- .../controller/use-workbar-controller.ts | 17 ++++++++++------- .../tools/review/session-review-panel.tsx | 17 ++++++++++------- .../side-conversation-session-family.ts | 4 ++++ .../features/workbar/ui/workbar-surface.tsx | 10 ++++++++-- 5 files changed, 38 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/renderer/features/workbar/README.md b/apps/desktop/src/renderer/features/workbar/README.md index 153343fc79..8f037514f8 100644 --- a/apps/desktop/src/renderer/features/workbar/README.md +++ b/apps/desktop/src/renderer/features/workbar/README.md @@ -21,8 +21,9 @@ Workbar is a vertical renderer feature. Its application-level model owns the right/bottom panel topology, active tabs, dimensions and persisted collapse -state. Tool data remains session-scoped, and the session content surface is -remounted when the active session changes. +state. Tool data remains session-scoped. The content surface is remounted when +navigation leaves the linked Session scope; within that scope tools receive a +new `sessionId` in place and must reset any session-derived data themselves. ## Dependency direction @@ -65,8 +66,9 @@ remounted when the active session changes. - Terminal ownership is registered as soon as `start` returns, before the tab state commits. Host projection excludes resources owned by another Session, so a Session switch cannot briefly reattach an old Terminal. -- Side Chat survives panel collapse and navigation within its linked Session - family; it is cleaned when its tab closes or navigation leaves that family. +- Side Chat survives panel collapse and navigation from its source Session to + linked descendants; it is cleaned when its tab closes or navigation leaves + that source/descendant scope. - Disposed Side Chat operations are fenced at every fork/send boundary; a late fork is cleaned and a late send cannot write back into an abandoned panel. - Inactive tabs stay mounted; their hooks receive the existing active/hidden diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index a4e150f272..51406c9e58 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -427,7 +427,8 @@ export function useWorkbarController( id: `side-chat:${panel.id}`, kind: 'side-chat', ordinal: - activeTab?.ordinal ?? reserveOrdinal('side-chat'), + (activePanel ? activeTab?.ordinal : undefined) ?? + reserveOrdinal('side-chat'), }, placement, ); @@ -700,13 +701,15 @@ export function useWorkbarController( () => new Set(activeSideConversationPanels.map((panel) => `side-chat:${panel.id}`)), [activeSideConversationPanels], ); - const sideConversationSurfaceKey = useMemo(() => { - if (activeSideConversationPanels.length === 0) return activeSessionId; - return ( + // Keep one WorkbarSurface mounted for the whole linked Session scope. This + // avoids remounting every tool when the first/last Side Chat tab appears and + // lets each tool receive the new sessionId and reset its own session data. + const sideConversationSurfaceKey = useMemo( + () => linkedSideConversationFamilyRootId(input.activeSession, input.sessions) ?? - activeSessionId - ); - }, [activeSessionId, activeSideConversationPanels.length, input.activeSession, input.sessions]); + activeSessionId, + [activeSessionId, input.activeSession, input.sessions], + ); const hostPanelsState = useMemo( () => projectWorkbarPanelsForSession( diff --git a/apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx index 3f6884d0c0..5da20f74f3 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx @@ -57,6 +57,7 @@ export function SessionReviewPanel(props: { const locale = useUiLocale(); const copy = getDesktopConversationCopy(locale).reviewPanel; const [gitResult, setGitResult] = useState(null); + const [gitResultSessionId, setGitResultSessionId] = useState(undefined); const [loading, setLoading] = useState(false); const [visibleFileCount, setVisibleFileCount] = useState(REVIEW_FILE_PAGE_SIZE); const [error, setError] = useState(null); @@ -73,6 +74,7 @@ export function SessionReviewPanel(props: { }); if (revision !== revisionRef.current) return; setGitResult(nextGit); + setGitResultSessionId(props.sessionId); } catch (nextError) { if (revision === revisionRef.current) { setError( @@ -113,7 +115,8 @@ export function SessionReviewPanel(props: { }; }, [load, props.active, props.sessionId, review]); - const gitSnapshot = gitResult?.ok ? gitResult.snapshot : null; + const currentGitResult = gitResultSessionId === props.sessionId ? gitResult : null; + const gitSnapshot = currentGitResult?.ok ? currentGitResult.snapshot : null; const gitFiles = gitSnapshot?.files ?? []; const visibleGitFiles = gitFiles.slice(0, visibleFileCount); const remainingGitFiles = Math.max(0, gitFiles.length - visibleGitFiles.length); @@ -123,21 +126,21 @@ export function SessionReviewPanel(props: { deletions: gitSnapshot?.deletions ?? 0, }; const sourceError = - gitResult?.ok !== false + currentGitResult?.ok !== false ? null - : gitResult.reason === 'not_git_repository' + : currentGitResult.reason === 'not_git_repository' ? copy.notGitRepository - : gitResult.reason === 'workspace_unavailable' + : currentGitResult.reason === 'workspace_unavailable' ? copy.workspaceUnavailable - : gitResult.reason === 'unborn_repository' + : currentGitResult.reason === 'unborn_repository' ? copy.unbornRepository - : gitResult.reason === 'invalid_base_branch' + : currentGitResult.reason === 'invalid_base_branch' ? copy.invalidBaseBranch : copy.gitFailed; const empty = !loading && !error && !sourceError && gitFiles.length === 0; useEffect(() => { setVisibleFileCount(REVIEW_FILE_PAGE_SIZE); - }, [gitSnapshot?.revision]); + }, [props.sessionId, gitSnapshot?.revision]); return (
session.id === activeSession.id)) return true; const sourceSession = sessions.find((session) => session.id === sourceSessionId); if (!sourceSession) return false; diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx index b5b8f3b8eb..f18f915956 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx @@ -703,6 +703,9 @@ export function WorkbarSurface(props: { }); const taskCount = sessionTodoActiveCount(sessionTodo.items); const [artifactCount, setArtifactCount] = useState(0); + const [artifactCountSessionId, setArtifactCountSessionId] = useState( + undefined, + ); const placements: SessionWorkbarPlacement[] = ['right', 'bottom']; const positionedTabs = placements.flatMap((placement) => props.panelsState[placement].tabs.map((tab) => ({ placement, tab })), @@ -740,7 +743,7 @@ export function WorkbarSurface(props: { activeTabId={showingLauncher ? null : panel.activeTabId} activeSideChatPanelIds={props.activeSideChatPanelIds} taskCount={taskCount} - artifactCount={artifactCount} + artifactCount={artifactCountSessionId === props.sessionId ? artifactCount : 0} onActivate={(tabId) => props.onActivateTab(placement, tabId)} onClose={(tab) => props.onCloseTab(placement, tab)} onCloseTabs={(tabs) => props.onCloseTabs(placement, tabs)} @@ -829,7 +832,10 @@ export function WorkbarSurface(props: { }> { + setArtifactCount(count); + setArtifactCountSessionId(props.sessionId); + }} onDismiss={() => props.onDismissPanel(placement)} /> From 6313450956ae686542e6b5854639e76b47cee486 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 09:49:49 +0800 Subject: [PATCH 3/7] fix(desktop): refresh renderer architecture token baseline Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 8fea02ce9f..0c94a64daa 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -979,8 +979,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 180, - "nonTriviaTokens": 15620 + "importSpecifiers": 184, + "nonTriviaTokens": 15684 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, From 8d139c8aac07e3af7f94095b5105c9ae5a341916 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 11:43:08 +0800 Subject: [PATCH 4/7] fix(desktop): preserve side chat through pending linked sessions --- .../main/__tests__/workbar-controller.test.ts | 27 +++++++ .../controller/use-workbar-controller.ts | 47 ++++++++--- .../side-conversation-session-family.ts | 80 ++++++++++++------- 3 files changed, 111 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index 6c87b186d9..f885cd158c 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -567,6 +567,33 @@ describe('useWorkbarController', () => { ); }); + it('keeps the previous family surface through a pending child catalog gap', async () => { + const { root } = installReactRenderer(); + const parent = session('parent'); + const child = session('child'); + child.subagent = { parentSessionId: parent.id }; + const services = createFakeWorkbarServices(); + + await act(async () => renderController(root, services, input(parent, [], [parent]))); + await act(async () => controller().commands.openTool('side-chat')); + const panelId = controller().host.quotes?.[0]?.id; + assert.ok(panelId); + + await act(async () => renderController(root, services, input(child, [], []))); + assert.equal(controller().host.surfaceKey, parent.id); + assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true); + assert.equal( + controller().host.panelsState.right.tabs.some( + (candidate) => candidate.id === `side-chat:${panelId}`, + ), + true, + ); + + await act(async () => renderController(root, services, input(child, [], [parent, child]))); + assert.equal(controller().host.surfaceKey, parent.id); + assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true); + }); + it('keeps the family surface mounted when one of multiple source panels closes', async () => { const { root } = installReactRenderer(); const parent = session('parent'); diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 51406c9e58..1ceee77e5d 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -181,6 +181,21 @@ export function useWorkbarController( const activeSessionId = input.activeSession?.id; const activeSessionIdRef = useRef(undefined); + const lastKnownFamilySessionRef = useRef(undefined); + const lastKnownFamilySessionsRef = useRef([]); + const activeSessionIsCataloged = Boolean( + input.activeSession && input.sessions.some((session) => session.id === input.activeSession!.id), + ); + if (activeSessionIsCataloged) { + lastKnownFamilySessionRef.current = input.activeSession; + lastKnownFamilySessionsRef.current = input.sessions; + } + const familySessionForSideChat = activeSessionIsCataloged + ? input.activeSession + : (lastKnownFamilySessionRef.current ?? input.activeSession); + const familySessionsForSideChat = activeSessionIsCataloged + ? input.sessions + : lastKnownFamilySessionsRef.current; const resourceGenerationRef = useRef(0); useLayoutEffect(() => { resourceGenerationRef.current += 1; @@ -555,8 +570,8 @@ export function useWorkbarController( (panel) => !isLinkedSideConversationSessionFamily( panel.sourceSessionId, - input.activeSession, - input.sessions, + familySessionForSideChat, + familySessionsForSideChat, ), ); if (stalePanels.length === 0) return; @@ -573,8 +588,8 @@ export function useWorkbarController( sideConversations.removePanels(staleIds); }, [ activeSessionId, - input.activeSession, - input.sessions, + familySessionForSideChat, + familySessionsForSideChat, layout.closeWorkbarTab, layout.workbarPanelsState, sideConversations.panels, @@ -691,11 +706,11 @@ export function useWorkbarController( sideConversations.panels.filter((panel) => isLinkedSideConversationSessionFamily( panel.sourceSessionId, - input.activeSession, - input.sessions, + familySessionForSideChat, + familySessionsForSideChat, ), ), - [input.activeSession, input.sessions, sideConversations.panels], + [familySessionForSideChat, familySessionsForSideChat, sideConversations.panels], ); const activeSideChatTabIds = useMemo( () => new Set(activeSideConversationPanels.map((panel) => `side-chat:${panel.id}`)), @@ -704,12 +719,18 @@ export function useWorkbarController( // Keep one WorkbarSurface mounted for the whole linked Session scope. This // avoids remounting every tool when the first/last Side Chat tab appears and // lets each tool receive the new sessionId and reset its own session data. - const sideConversationSurfaceKey = useMemo( - () => - linkedSideConversationFamilyRootId(input.activeSession, input.sessions) ?? - activeSessionId, - [activeSessionId, input.activeSession, input.sessions], - ); + const sideConversationSurfaceKeyRef = useRef(undefined); + const sideConversationSurfaceKey = useMemo(() => { + const familyRoot = linkedSideConversationFamilyRootId( + familySessionForSideChat, + familySessionsForSideChat, + ); + if (familyRoot !== undefined) { + sideConversationSurfaceKeyRef.current = familyRoot; + return familyRoot; + } + return sideConversationSurfaceKeyRef.current ?? activeSessionId; + }, [activeSessionId, familySessionForSideChat, familySessionsForSideChat]); const hostPanelsState = useMemo( () => projectWorkbarPanelsForSession( diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts index 4d9d3528cd..18fd59eb3a 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts @@ -17,12 +17,14 @@ * under the License. */ +import type { SessionSummary } from '@maka/core/session'; import { - linkedSubagentParentSessionId, - type SessionSummary, -} from '@maka/core/session'; + collapseSessionRevisions, + projectRevisionLinkedSessionTree, + sessionRevisionFamilyId, +} from '@maka/core/session-revisions'; -type LinkedSession = Pick; +type LinkedSession = SessionSummary; /** * Whether the active Session is the source itself or a linked descendant of @@ -39,15 +41,27 @@ export function isLinkedSideConversationSessionFamily( // before its catalog row arrives. Keep the panel through that refresh; a // missing source is only destructive once navigation has left its id. if (sourceSessionId === activeSession.id) return true; - // A pending active Session has no lineage metadata yet. Do not destroy a - // live Side Chat during that short catalog gap; once the row arrives the - // normal descendant check below decides whether it belongs to this scope. - if (!sessions.some((session) => session.id === activeSession.id)) return true; + // A pending active Session has no catalog lineage yet. The controller keeps + // the previous known family alive during that short gap; this helper itself + // must not retain every panel for an unrelated unknown Session. + if (!sessions.some((session) => session.id === activeSession.id)) return false; const sourceSession = sessions.find((session) => session.id === sourceSessionId); if (!sourceSession) return false; - const sessionsById = new Map(sessions.map((session) => [session.id, session])); - return reachesSession(activeSession, sourceSessionId, sessionsById); + const logicalSessions = collapseSessionRevisions(sessions, activeSession.id); + const representativeByFamilyId = new Map( + logicalSessions.map((session) => [sessionRevisionFamilyId(session), session.id]), + ); + const sourceId = + representativeByFamilyId.get(sessionRevisionFamilyId(sourceSession)) ?? sourceSession.id; + const activeId = + representativeByFamilyId.get(sessionRevisionFamilyId(activeSession)) ?? activeSession.id; + const tree = projectRevisionLinkedSessionTree(sessions, activeSession.id); + const parentByChildId = new Map(); + for (const [parentId, children] of tree.childrenByParentId) { + for (const child of children) parentByChildId.set(child.id, parentId); + } + return reachesSession(activeId, sourceId, parentByChildId); } /** @@ -60,34 +74,40 @@ export function linkedSideConversationFamilyRootId( sessions: readonly LinkedSession[], ): string | undefined { if (!activeSession) return undefined; - const sessionsById = new Map(sessions.map((session) => [session.id, session])); + if (!sessions.some((session) => session.id === activeSession.id)) return undefined; + const logicalSessions = collapseSessionRevisions(sessions, activeSession.id); + const activeRepresentative = + logicalSessions.find( + (session) => sessionRevisionFamilyId(session) === sessionRevisionFamilyId(activeSession), + ) ?? activeSession; + const tree = projectRevisionLinkedSessionTree(sessions, activeSession.id); + const parentByChildId = new Map(); + for (const [parentId, children] of tree.childrenByParentId) { + for (const child of children) parentByChildId.set(child.id, parentId); + } const visited = new Set(); - let current = activeSession; - while (!visited.has(current.id)) { - visited.add(current.id); - const parentSessionId = linkedSubagentParentSessionId(current); - if (!parentSessionId) return current.id; - const parent = sessionsById.get(parentSessionId); - if (!parent) return current.id; - current = parent; + let currentId = activeRepresentative.id; + while (!visited.has(currentId)) { + visited.add(currentId); + const parentId = parentByChildId.get(currentId); + if (!parentId) return currentId; + currentId = parentId; } - return current.id; + return currentId; } function reachesSession( - start: LinkedSession, + startId: string, targetSessionId: string, - sessionsById: ReadonlyMap, + parentByChildId: ReadonlyMap, ): boolean { const visited = new Set(); - let current: LinkedSession | undefined = start; - while (current) { - if (current.id === targetSessionId) return true; - if (visited.has(current.id)) return false; - visited.add(current.id); - const parentSessionId = linkedSubagentParentSessionId(current); - if (!parentSessionId) return false; - current = sessionsById.get(parentSessionId); + let currentId: string | undefined = startId; + while (currentId) { + if (currentId === targetSessionId) return true; + if (visited.has(currentId)) return false; + visited.add(currentId); + currentId = parentByChildId.get(currentId); } return false; } From daa6136e8ff839a95f65d2557e36a56fa4ff37c7 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 12:07:26 +0800 Subject: [PATCH 5/7] test(desktop): refresh renderer architecture baseline --- apps/desktop/renderer-architecture.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 0c94a64daa..7ae2fa5021 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -979,8 +979,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 184, - "nonTriviaTokens": 15684 + "importSpecifiers": 180, + "nonTriviaTokens": 15618 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, From fb64d840fd1fe0e5822d5a735b0dc73113546e58 Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 12:09:00 +0800 Subject: [PATCH 6/7] fix(desktop): cache linked session family projection --- .../side-conversation-session-family.ts | 67 ++++++++++++++----- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts index 18fd59eb3a..f12d62a82a 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/side-conversation-session-family.ts @@ -26,6 +26,50 @@ import { type LinkedSession = SessionSummary; +interface SessionFamilyProjection { + logicalSessions: readonly LinkedSession[]; + representativeByFamilyId: ReadonlyMap; + parentByChildId: ReadonlyMap; +} + +// Both the stale-panel effect and the active-tab projection ask the same +// family questions during a render. Cache the immutable projection by the +// catalog array and active id so a streaming catalog revision builds the +// revision-aware maps once, rather than once per panel. +const sessionFamilyProjectionCache = new WeakMap< + readonly LinkedSession[], + Map +>(); + +function projectSessionFamily( + sessions: readonly LinkedSession[], + activeId: string, +): SessionFamilyProjection { + const cacheKey = activeId; + const cachedByActiveId = sessionFamilyProjectionCache.get(sessions); + const cached = cachedByActiveId?.get(cacheKey); + if (cached) return cached; + + const logicalSessions = collapseSessionRevisions(sessions, activeId); + const representativeByFamilyId = new Map( + logicalSessions.map((session) => [sessionRevisionFamilyId(session), session.id]), + ); + const tree = projectRevisionLinkedSessionTree(sessions, activeId); + const parentByChildId = new Map(); + for (const [parentId, children] of tree.childrenByParentId) { + for (const child of children) parentByChildId.set(child.id, parentId); + } + const projection: SessionFamilyProjection = { + logicalSessions, + representativeByFamilyId, + parentByChildId, + }; + const nextCache = cachedByActiveId ?? new Map(); + nextCache.set(cacheKey, projection); + if (!cachedByActiveId) sessionFamilyProjectionCache.set(sessions, nextCache); + return projection; +} + /** * Whether the active Session is the source itself or a linked descendant of * the source. Ordinary branches deliberately do not participate: @@ -48,19 +92,14 @@ export function isLinkedSideConversationSessionFamily( const sourceSession = sessions.find((session) => session.id === sourceSessionId); if (!sourceSession) return false; - const logicalSessions = collapseSessionRevisions(sessions, activeSession.id); - const representativeByFamilyId = new Map( - logicalSessions.map((session) => [sessionRevisionFamilyId(session), session.id]), - ); + const { + representativeByFamilyId, + parentByChildId, + } = projectSessionFamily(sessions, activeSession.id); const sourceId = representativeByFamilyId.get(sessionRevisionFamilyId(sourceSession)) ?? sourceSession.id; const activeId = representativeByFamilyId.get(sessionRevisionFamilyId(activeSession)) ?? activeSession.id; - const tree = projectRevisionLinkedSessionTree(sessions, activeSession.id); - const parentByChildId = new Map(); - for (const [parentId, children] of tree.childrenByParentId) { - for (const child of children) parentByChildId.set(child.id, parentId); - } return reachesSession(activeId, sourceId, parentByChildId); } @@ -75,16 +114,14 @@ export function linkedSideConversationFamilyRootId( ): string | undefined { if (!activeSession) return undefined; if (!sessions.some((session) => session.id === activeSession.id)) return undefined; - const logicalSessions = collapseSessionRevisions(sessions, activeSession.id); + const { logicalSessions, parentByChildId } = projectSessionFamily( + sessions, + activeSession.id, + ); const activeRepresentative = logicalSessions.find( (session) => sessionRevisionFamilyId(session) === sessionRevisionFamilyId(activeSession), ) ?? activeSession; - const tree = projectRevisionLinkedSessionTree(sessions, activeSession.id); - const parentByChildId = new Map(); - for (const [parentId, children] of tree.childrenByParentId) { - for (const child of children) parentByChildId.set(child.id, parentId); - } const visited = new Set(); let currentId = activeRepresentative.id; while (!visited.has(currentId)) { From 9ce335944122d236f8ee93e3537eda799fc1b3ab Mon Sep 17 00:00:00 2001 From: testikun Date: Fri, 4 Sep 2026 14:48:49 +0800 Subject: [PATCH 7/7] test(desktop): cover linked-session side chat continuity --- apps/desktop/e2e/fixtures.ts | 90 ++++++++++++++++++++++-- apps/desktop/e2e/session-workbar.spec.ts | 44 +++++++++++- 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 02130965c3..6d2ed2e8d7 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -140,7 +140,7 @@ export async function waitForInvocableSkills( * backend (BackendRegistry override in main); this only satisfies the UI * readiness gates. Kept in the fixture so test data stays out of production main. */ -async function seedE2eConnection(userDataDir: string): Promise { +async function seedE2eConnection(userDataDir: string): Promise { const workspaceRoot = path.join(userDataDir, 'workspaces', 'default'); const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); @@ -197,6 +197,7 @@ async function seedE2eConnection(userDataDir: string): Promise { if (defaultTarget.kind !== 'committed') { throw new Error(`E2E default target seed was not committed: ${defaultTarget.kind}`); } + return connection.connectionId; } finally { await owner.close(); } @@ -231,20 +232,28 @@ async function seedRailRenderSessions(userDataDir: string): Promise { } } -async function seedParentRemovalSessions(userDataDir: string): Promise { +async function seedParentRemovalSessions( + userDataDir: string, + connectionId: string, +): Promise { const workspaceRoot = path.join(userDataDir, 'workspaces', 'default'); + const projectRoot = path.join(userDataDir, 'project'); + const now = Date.UTC(2026, 4, 22, 3, 0, 0); + await mkdir(projectRoot, { recursive: true }); const store = createSessionStore(workspaceRoot); try { const parent = await store.create({ - cwd: path.join(userDataDir, 'project'), + cwd: projectRoot, + llmConnectionId: connectionId, llmConnectionSlug: 'e2e', model: 'claude-sonnet-4-5-20250929', permissionMode: 'ask', name: PARENT_REMOVAL_PARENT_NAME, labels: [], }); - await store.createSubagent({ - cwd: path.join(userDataDir, 'project'), + const child = await store.createSubagent({ + cwd: projectRoot, + llmConnectionId: connectionId, llmConnectionSlug: 'e2e', model: 'claude-sonnet-4-5-20250929', permissionMode: 'ask', @@ -277,6 +286,70 @@ async function seedParentRemovalSessions(userDataDir: string): Promise { initialRunId: 'e2e-child-run', }, }); + await store.appendMessages(parent.id, [ + { + type: 'user', + id: 'e2e-parent-user', + turnId: 'e2e-parent-turn', + ts: now - 2_000, + text: '请让实现子任务检查侧边对话的保留行为。', + }, + { + type: 'tool_call', + id: 'e2e-spawn-call', + turnId: 'e2e-parent-turn', + ts: now - 1_900, + toolName: 'spawn_subagent', + displayName: 'Implementation', + intent: '检查侧边对话在父子任务间的连续性', + args: {}, + }, + { + type: 'tool_result', + id: 'e2e-spawn-result', + turnId: 'e2e-parent-turn', + ts: now - 1_800, + toolUseId: 'e2e-spawn-call', + isError: false, + content: { + kind: 'subagent', + childSessionId: child.header.id, + agentId: 'implementation', + agentName: 'Implementation', + turnId: 'e2e-child-turn', + runId: 'e2e-child-run', + status: 'completed', + permissionMode: 'ask', + summary: '已完成侧边对话连续性检查', + artifactIds: [], + }, + }, + { + type: 'assistant', + id: 'e2e-parent-assistant', + turnId: 'e2e-parent-turn', + ts: now - 1_700, + text: '实现子任务已完成检查。', + modelId: 'claude-sonnet-4-5-20250929', + }, + ]); + await store.appendMessages(child.header.id, [ + { + type: 'user', + id: 'e2e-child-user', + turnId: 'e2e-child-turn', + ts: now - 1_600, + text: '检查父任务中打开的侧边对话。', + }, + { + type: 'assistant', + id: 'e2e-child-assistant', + turnId: 'e2e-child-turn', + ts: now - 1_500, + text: '已确认切换到子任务后,父任务的侧边对话应继续保留。', + modelId: 'claude-sonnet-4-5-20250929', + }, + ]); } finally { await store.close?.(); } @@ -450,8 +523,11 @@ async function withE2eWindow( const mainLogs: string[] = []; const rendererLogs: string[] = []; try { - if (seed) await seedE2eConnection(userDataDir); - if (parentRemovalSessions) await seedParentRemovalSessions(userDataDir); + const e2eConnectionId = seed ? await seedE2eConnection(userDataDir) : undefined; + if (parentRemovalSessions) { + if (!e2eConnectionId) throw new Error('Parent-removal fixture requires a seeded connection'); + await seedParentRemovalSessions(userDataDir, e2eConnectionId); + } if (railRenderSessions) await seedRailRenderSessions(userDataDir); if (invocableSkills) await seedE2eInvocableSkills(userDataDir); if (gitReviewExtraFiles !== undefined) { diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index f6bee3f5ca..894dfc4f3f 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -17,7 +17,13 @@ * under the License. */ -import { COMPOSER_INPUT, test, expect } from './fixtures'; +import { + COMPOSER_INPUT, + PARENT_REMOVAL_CHILD_NAME, + PARENT_REMOVAL_PARENT_NAME, + test, + expect, +} from './fixtures'; import type { Page } from '@playwright/test'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -469,3 +475,39 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc .toBe(false); await expect(composer).toHaveText(''); }); + +test('parent Side Chat and its staged quote survive linked child navigation', async ({ + parentRemovalWindow: page, +}) => { + await page.getByRole('button', { name: '展开侧边栏' }).click(); + const taskList = page.getByRole('navigation', { name: '任务列表' }); + await taskList.getByText(PARENT_REMOVAL_PARENT_NAME, { exact: true }).click(); + const parentAnswer = page + .getByLabel('Maka 的回答') + .getByText('实现子任务已完成检查。'); + await expect(parentAnswer).toBeVisible(); + const bounds = await parentAnswer.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const rect = range.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }); + const selectionY = bounds.y + bounds.height / 2; + await page.mouse.move(bounds.x + 2, selectionY); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width - 2, selectionY, { steps: 5 }); + await page.mouse.up(); + await page.getByRole('button', { name: '在侧栏追问' }).click(); + const companion = page.locator('.maka-quote-companion'); + const stagedQuote = companion.getByRole('group', { name: '附加内容' }); + await expect(stagedQuote).toBeVisible(); + const stagedQuoteText = await stagedQuote.textContent(); + expect(stagedQuoteText).toBeTruthy(); + + const parentTurn = parentAnswer.locator('xpath=ancestor::*[@data-turn-id][1]'); + await parentTurn.getByText('Implementation', { exact: true }).click(); + await expect(page.getByText(PARENT_REMOVAL_CHILD_NAME, { exact: true })).toBeVisible(); + await expect(page.getByText('已确认切换到子任务后,父任务的侧边对话应继续保留。')).toBeVisible(); + await expect(companion).toBeVisible(); + await expect(stagedQuote).toHaveText(stagedQuoteText!); +});