diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index b238e7dfd0..ae00f7ed2d 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -126,7 +126,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); @@ -183,6 +183,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(); } @@ -217,20 +218,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', @@ -263,6 +272,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?.(); } @@ -436,8 +509,11 @@ export 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/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 098cfae5ac..64d66314af 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -894,7 +894,7 @@ "react": 1 }, "importSpecifiers": 116, - "nonTriviaTokens": 14318 + "nonTriviaTokens": 14316 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, 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 159cbb3311..a3db9c2c7b 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -1996,6 +1996,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)], @@ -2024,6 +2025,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 c44f0a7ccc..bc8d433e7a 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -24,6 +24,7 @@ import { act, createElement, StrictMode, useLayoutEffect } from 'react'; import type { ShellRunUpdate } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; import { LocaleProvider } from '@maka/ui'; +import { pendingSessionView } from '../../renderer/pending-session-view.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeWorkbarServices, @@ -60,6 +61,15 @@ function shellUpdate(sessionId: string, ref: string): ShellRunUpdate { result: { ref }, } as ShellRunUpdate; } + +function pendingSession(sessionId: string): SessionSummary { + return pendingSessionView({ + sessionId, + name: sessionId, + permissionMode: 'ask', + }); +} + let latestController: WorkbarController | undefined; let controllerRenderSnapshots: Array<{ activeId: string | undefined; @@ -118,13 +128,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}`), @@ -576,6 +589,261 @@ 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 placeholderChild = pendingSession('pending-child'); + const child = session('pending-child'); + child.subagent = { parentSessionId: source.id }; + const services = createFakeWorkbarServices(); + + await act(async () => renderController(root, services, input(source, [], [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(placeholderChild, [], [source])), + ); + assert.equal(controller().host.surfaceKey, source.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, [], [source, child])), + ); + assert.equal(controller().host.surfaceKey, source.id); + assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true); + }); + + it('keeps the family surface key stable across source revision updates', async () => { + const { root } = installReactRenderer(); + const services = createFakeWorkbarServices(); + const source = session('source-a'); + source.revisionRootSessionId = 'source-root'; + const sourceRevision = session('source-a-revision'); + sourceRevision.revisionRootSessionId = 'source-root'; + const child = session('child-a'); + child.subagent = { parentSessionId: sourceRevision.id }; + + await act(async () => + renderController(root, services, input(source, [], [source])), + ); + await act(async () => controller().commands.openTool('side-chat')); + const panelId = controller().host.quotes?.[0]?.id; + assert.ok(panelId); + assert.equal(controller().host.surfaceKey, 'source-root'); + + await act(async () => + renderController(root, services, input(sourceRevision, [], [source, sourceRevision, child])), + ); + assert.equal(controller().host.surfaceKey, 'source-root'); + assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true); + + await act(async () => + renderController(root, services, input(child, [], [source, sourceRevision, child])), + ); + assert.equal(controller().host.surfaceKey, 'source-root'); + assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true); + }); + + 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('cleans Side Chat when a new task clears the active Session', async () => { + const { root } = installReactRenderer(); + const parent = session('parent'); + 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(undefined, [], [parent]))); + + assert.equal(controller().host.activeId, undefined); + 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 Side Chat for an uncataloged unrelated Session', async () => { + const { root } = installReactRenderer(); + const parent = session('parent'); + const unrelated = session('unrelated'); + 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(unrelated, [], [parent]))); + + assert.equal(controller().host.activeId, unrelated.id); + 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('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 63a7300f0e..5a36be53a5 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1450,9 +1450,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 85c141f985..67c2f23b7c 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 @@ -71,8 +72,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 is cleaned only when its tab closes or - when navigation leaves its source session. +- 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 1ec9769211..f74ea58410 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'; import { LiveContextUsageProbe } from '../tools/inspector/live-context-usage-probe.js'; @@ -91,6 +95,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; @@ -136,6 +141,24 @@ function terminalResourceKey(sessionId: string, ref: string): string { return `${sessionId}\u0000${ref}`; } +function pendingActiveSessionBelongsToKnownFamily( + activeSession: SessionSummary | undefined, + knownSessions: readonly SessionSummary[], +): boolean { + if (!activeSession || knownSessions.some((session) => session.id === activeSession.id)) { + return false; + } + // Pending catalog views (from `pendingSessionView`) carry no lineage metadata. + // Keep the previous catalog family through that boundary so we don't dismiss a live + // Side Conversation while the actual linked child row is still loading. + if (activeSession.model === '' && activeSession.llmConnectionSlug === '') { + return knownSessions.length > 0; + } + const parentSessionId = + activeSession.subagent?.parentSessionId ?? activeSession.subagentParent?.parentSessionId; + return parentSessionId !== undefined && knownSessions.some((session) => session.id === parentSessionId); +} + function projectWorkbarPanelsForSession( panels: SessionWorkbarPanelsState, activeSessionId: string | undefined, @@ -185,6 +208,31 @@ export function useWorkbarController( const [, setLiveBrowserSessionIds] = useState([]); 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 canUseLastKnownFamily = pendingActiveSessionBelongsToKnownFamily( + input.activeSession, + lastKnownFamilySessionsRef.current, + ); + const familySessionForSideChat = + activeSessionIsCataloged || canUseLastKnownFamily + ? activeSessionIsCataloged + ? input.activeSession + : lastKnownFamilySessionRef.current + : input.activeSession; + const familySessionsForSideChat = + activeSessionIsCataloged || canUseLastKnownFamily + ? activeSessionIsCataloged + ? input.sessions + : lastKnownFamilySessionsRef.current + : input.sessions; const resourceGenerationRef = useRef(0); useLayoutEffect(() => { resourceGenerationRef.current += 1; @@ -431,7 +479,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, ); @@ -558,7 +607,12 @@ export function useWorkbarController( useLayoutEffect(() => { const stalePanels = sideConversations.panels.filter( - (panel) => panel.sourceSessionId !== activeSessionId, + (panel) => + !isLinkedSideConversationSessionFamily( + panel.sourceSessionId, + familySessionForSideChat, + familySessionsForSideChat, + ), ); if (stalePanels.length === 0) return; const staleIds = new Set(stalePanels.map((panel) => panel.id)); @@ -577,6 +631,8 @@ export function useWorkbarController( }, [ activeSessionId, layout.closeWorkbarTabs, + familySessionForSideChat, + familySessionsForSideChat, layout.workbarPanelsState, sideConversations.panels, sideConversations.removePanels, @@ -687,15 +743,34 @@ 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, + familySessionForSideChat, + familySessionsForSideChat, + ), ), - [activeSessionId, sideConversations.panels], + [familySessionForSideChat, familySessionsForSideChat, sideConversations.panels], + ); + const activeSideChatTabIds = useMemo( + () => new Set(activeSideConversationPanels.map((panel) => `side-chat:${panel.id}`)), + [activeSideConversationPanels], ); + // 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(() => { + const familyRoot = linkedSideConversationFamilyRootId( + familySessionForSideChat, + familySessionsForSideChat, + ); + if (familyRoot !== undefined) { + return familyRoot; + } + return activeSessionId; + }, [activeSessionId, familySessionForSideChat, familySessionsForSideChat]); const hostPanelsState = useMemo( () => projectWorkbarPanelsForSession( @@ -705,7 +780,6 @@ export function useWorkbarController( ), [activeSessionId, activeSideChatTabIds, layout.workbarPanelsState], ); - return { commands, LiveContextUsageProbe, @@ -723,6 +797,7 @@ export function useWorkbarController( rightWidth: layout.workbarWidth, bottomHeight: layout.bottomPanelHeight, panelsState: hostPanelsState, + surfaceKey: sideConversationSurfaceKey, onActivateTab: layout.activateWorkbarTab, onCloseTab: closeTab, onCloseTabs: closeTabs, @@ -737,9 +812,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/review/session-review-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx index a31b9f34cf..5c6a929596 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( @@ -111,7 +113,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); @@ -121,21 +124,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 (
; + 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: + * 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; + // 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 { + representativeByFamilyId, + parentByChildId, + } = projectSessionFamily(sessions, activeSession.id); + const sourceId = + representativeByFamilyId.get(sessionRevisionFamilyId(sourceSession)) ?? sourceSession.id; + const activeId = + representativeByFamilyId.get(sessionRevisionFamilyId(activeSession)) ?? activeSession.id; + return reachesSession(activeId, sourceId, parentByChildId); +} + +/** + * 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; + if (!sessions.some((session) => session.id === activeSession.id)) return undefined; + const { logicalSessions, parentByChildId } = projectSessionFamily( + sessions, + activeSession.id, + ); + const logicalSessionsById = new Map( + logicalSessions.map((session) => [session.id, session]), + ); + const activeRepresentative = + logicalSessions.find( + (session) => sessionRevisionFamilyId(session) === sessionRevisionFamilyId(activeSession), + ) ?? activeSession; + const visited = new Set(); + let currentSession = logicalSessionsById.get(activeRepresentative.id) ?? activeRepresentative; + while (!visited.has(currentSession.id)) { + visited.add(currentSession.id); + const parentId = parentByChildId.get(currentSession.id); + if (!parentId) return sessionRevisionFamilyId(currentSession); + const parentSession = logicalSessionsById.get(parentId); + if (!parentSession) return sessionRevisionFamilyId(currentSession); + currentSession = parentSession; + } + return sessionRevisionFamilyId(currentSession); +} + +function reachesSession( + startId: string, + targetSessionId: string, + parentByChildId: ReadonlyMap, +): boolean { + const visited = new Set(); + 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; +} 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 1a65ba4024..698fecb60d 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 @@ -116,6 +116,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[]; @@ -215,13 +217,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, @@ -246,9 +249,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); @@ -759,7 +760,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 2cce85524e..bdadb29c77 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: ( @@ -103,6 +104,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; @@ -166,7 +168,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; @@ -545,13 +546,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 = ( {})}