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!); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 8fea02ce9f..7ae2fa5021 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -980,7 +980,7 @@ "react": 1 }, "importSpecifiers": 180, - "nonTriviaTokens": 15620 + "nonTriviaTokens": 15618 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, 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..f885cd158c 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,173 @@ 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 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'); + 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..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 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 59662743ab..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 @@ -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; @@ -176,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; @@ -422,7 +442,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, ); @@ -546,7 +567,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)); @@ -562,6 +588,8 @@ export function useWorkbarController( sideConversations.removePanels(staleIds); }, [ activeSessionId, + familySessionForSideChat, + familySessionsForSideChat, layout.closeWorkbarTab, layout.workbarPanelsState, sideConversations.panels, @@ -673,15 +701,36 @@ 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 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( @@ -691,7 +740,6 @@ export function useWorkbarController( ), [activeSessionId, activeSideChatTabIds, layout.workbarPanelsState], ); - return { commands, selectors: { @@ -708,6 +756,7 @@ export function useWorkbarController( rightWidth: layout.workbarWidth, bottomHeight: layout.bottomPanelHeight, panelsState: hostPanelsState, + surfaceKey: sideConversationSurfaceKey, onActivateTab: layout.activateWorkbarTab, onCloseTab: closeTab, onCloseTabs: closeTabs, @@ -726,9 +775,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 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 (
; + 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 activeRepresentative = + logicalSessions.find( + (session) => sessionRevisionFamilyId(session) === sessionRevisionFamilyId(activeSession), + ) ?? activeSession; + const visited = new Set(); + let currentId = activeRepresentative.id; + while (!visited.has(currentId)) { + visited.add(currentId); + const parentId = parentByChildId.get(currentId); + if (!parentId) return currentId; + currentId = parentId; + } + return currentId; +} + +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 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; @@ -702,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 })), @@ -739,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)} @@ -828,7 +832,10 @@ export function WorkbarSurface(props: { }> { + setArtifactCount(count); + setArtifactCountSessionId(props.sessionId); + }} onDismiss={() => props.onDismissPanel(placement)} /> @@ -846,13 +853,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 = ( {})}