Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -980,7 +980,7 @@
"react": 1
},
"importSpecifiers": 180,
"nonTriviaTokens": 15620
"nonTriviaTokens": 15618
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/__tests__/quote-companion-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down Expand Up @@ -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)],
Expand Down
172 changes: 171 additions & 1 deletion apps/desktop/src/main/__tests__/workbar-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`),
Expand Down Expand Up @@ -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 () => {
Comment thread
testikun marked this conversation as resolved.
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();
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/renderer/features/workbar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string> | undefined;
Expand Down Expand Up @@ -176,6 +181,21 @@ export function useWorkbarController(

const activeSessionId = input.activeSession?.id;
const activeSessionIdRef = useRef<string | undefined>(undefined);
const lastKnownFamilySessionRef = useRef<SessionSummary | undefined>(undefined);
const lastKnownFamilySessionsRef = useRef<readonly SessionSummary[]>([]);
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;
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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));
Expand All @@ -562,6 +588,8 @@ export function useWorkbarController(
sideConversations.removePanels(staleIds);
}, [
activeSessionId,
familySessionForSideChat,
familySessionsForSideChat,
layout.closeWorkbarTab,
layout.workbarPanelsState,
sideConversations.panels,
Expand Down Expand Up @@ -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<string | undefined>(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(
Expand All @@ -691,7 +740,6 @@ export function useWorkbarController(
),
[activeSessionId, activeSideChatTabIds, layout.workbarPanelsState],
);

return {
commands,
selectors: {
Expand All @@ -708,6 +756,7 @@ export function useWorkbarController(
rightWidth: layout.workbarWidth,
bottomHeight: layout.bottomPanelHeight,
panelsState: hostPanelsState,
surfaceKey: sideConversationSurfaceKey,
onActivateTab: layout.activateWorkbarTab,
onCloseTab: closeTab,
onCloseTabs: closeTabs,
Expand All @@ -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,
Expand Down
Loading