Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5b09a63
feat(desktop): preserve Side Conversations across linked sessions
Sep 3, 2026
aee45b4
feat(desktop): preserve Side Conversations across linked sessions
Sep 3, 2026
ef1c674
fix(desktop): refresh renderer architecture token baseline
Sep 4, 2026
63bc672
fix(desktop): preserve side chat through pending linked sessions
Sep 4, 2026
ebb2889
test(desktop): refresh renderer architecture baseline
Sep 4, 2026
ef15f5f
fix(desktop): cache linked session family projection
Sep 4, 2026
490e0fa
test(desktop): cover linked-session side chat continuity
Sep 4, 2026
5d216a6
chore(desktop): refresh renderer architecture ledger after rebase
Sep 5, 2026
f0219b2
fix(desktop): clean side chat on unrelated navigation
Sep 5, 2026
3f96508
ci: retry flaky Storybook smoke check
Sep 5, 2026
f7cc298
fix: treat pending session view as known side-chat family anchor
testikun Sep 8, 2026
bff6929
fix: preserve side-chat retention across pending and revision transit…
testikun Sep 8, 2026
dbe1a9c
Merge remote-tracking branch 'apache/main' into codex/issue-4494-dele…
Sep 8, 2026
e0b42e2
Merge remote-tracking branch 'apache/main' into codex/issue-4494-dele…
Sep 8, 2026
d016aa7
Merge main into codex/issue-4494-delegated after PR recovery
testikun Sep 9, 2026
2830a7b
Merge upstream/main into PR 4625
testikun Sep 9, 2026
7765d19
fix(desktop): sync AppShell architecture ledger after merge
testikun Sep 9, 2026
3a4df15
fix(desktop): retain side chat across pending Host selection only
testikun Sep 10, 2026
357747c
fix(desktop): retain side chat only for linked sessions
testikun Sep 14, 2026
4243253
refactor(desktop): keep Workbar wiring within shell budget
testikun Sep 14, 2026
44cb201
Merge upstream/main into Side Conversation navigation PR
testikun Sep 15, 2026
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 @@ -853,7 +853,7 @@
"react": 1
},
"importSpecifiers": 103,
"nonTriviaTokens": 13381
"nonTriviaTokens": 13379
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
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 @@ -3301,6 +3301,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 @@ -3332,6 +3333,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
249 changes: 248 additions & 1 deletion apps/desktop/src/main/__tests__/workbar-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { ShellRunUpdate } from '@maka/core/events';
import type { SessionSummary } from '@maka/core/session';
import type { WorkBoardActiveItem, WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board';
import { LocaleProvider, type ToastApi } from '@maka/ui';
import { pendingSessionView } from '../../renderer/pending-session-view.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import { TerminalCloseIntents } from '../terminal-close-intents.js';
import type { TerminalCloseChange } from '../../shared/runtime-host-identity.js';
Expand Down Expand Up @@ -72,6 +73,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 latestTaskEntryController: TaskEntryController | undefined;
let controllerRenderSnapshots: Array<{
Expand Down Expand Up @@ -169,14 +179,17 @@ function createFakeToastApi(errors: string[] = []): ToastApi {
function input(
activeSession: SessionSummary | undefined,
toastApi: ToastApi = createFakeToastApi(),
sessionCatalog?: readonly SessionSummary[],
): UseWorkbarControllerInput {
const sessions = sessionCatalog ?? (activeSession ? [activeSession] : []);
return {
available: true,
layoutSessionId: activeSession?.id,
activeSession,
sessions,
projectId: activeSession?.projectId,
projectAliases: [],
authoritativeSessionIds: new Set(['a', 'b', ...(activeSession ? [activeSession.id] : [])]),
authoritativeSessionIds: new Set(['a', 'b', ...sessions.map((session) => session.id)]),
shellObscured: false,
modelChoices: [],
toastApi,
Expand Down Expand Up @@ -941,6 +954,240 @@ 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, createFakeToastApi(), 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, createFakeToastApi(), sessions)));

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, createFakeToastApi(), sessions)));
assert.equal(controller().host.quotes?.[0], panel);
assert.equal(
controller().host.panelsState.right.tabs.some(
(candidate) => candidate.id === tab.id,
),
true,
);
});

it('retains Side Chat 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, createFakeToastApi(), [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(sourceRevision, createFakeToastApi(), [source, sourceRevision, child])),
);
assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true);

await act(async () =>
renderController(root, services, input(child, createFakeToastApi(), [source, sourceRevision, child])),
);
assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true);
});

it('retains the source and Side Chat 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, createFakeToastApi(), [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, createFakeToastApi(), [parent]),
layoutSessionId: child.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, createFakeToastApi(), [parent, child])));
assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true);
});

it('cleans Side Chat when its source is deleted during a pending navigation', async () => {
const { root } = installReactRenderer();
const parent = session('parent');
const services = createFakeWorkbarServices();
await act(async () => renderController(root, services, input(parent, createFakeToastApi(), [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, createFakeToastApi(), []), layoutSessionId: 'child',
}));
assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), false);
assert.equal(controller().host.panelsState.right.tabs.some(
(tab) => tab.id === `side-chat:${panelId}`,
), false);
});

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, createFakeToastApi(), [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, createFakeToastApi(), [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, createFakeToastApi(), [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, createFakeToastApi(), [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 remaining Side Chat 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, createFakeToastApi(), 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, createFakeToastApi(), sessions)));
await act(async () => controller().commands.openTool('side-chat'));
const childPanel = controller().host.quotes?.find(
(panel) => panel.sourceSessionId === child.id,
);
assert.ok(childPanel);

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.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, createFakeToastApi(), [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, createFakeToastApi(), [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, createFakeToastApi(), 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, createFakeToastApi(), 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('keeps a newly created companion hidden through panel changes and stale catalogs until cleanup', 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 @@ -1342,9 +1342,10 @@ function AppShellContent({
available: workbarAvailable,
layoutSessionId: activeId,
activeSession: activeHostSession,
sessions,
projectId: currentProjectId,
projectAliases: currentProject?.aliases ?? [],
authoritativeSessionIds: authoritativeSessionIds ?? undefined,
authoritativeSessionIds,
shellObscured,
modelChoices: chatModelChoices,
toastApi,
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/src/renderer/features/workbar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@

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 stays mounted
across navigation; tools receive a new `sessionId` in place and must reset
any session-derived data themselves. Side Chat keeps its immutable source owner
while navigating that source's revision family and linked descendants.

## Dependency direction

Expand Down Expand Up @@ -80,7 +82,9 @@ remounted when the active session changes.
- Removing a Session from the authoritative catalog retires its Terminal views.
Host owns admission of Session retirement while processes are live.
- Side Chat survives panel collapse and is cleaned only when its tab closes or
when navigation leaves its source session.
when navigation leaves its source Session and linked descendants. Unknown
navigation targets defer cleanup only while the previous source remains in
the current catalog.
- Fork creation hides the internal Session until cleanup succeeds. Catalog
absence does not confirm cleanup because a snapshot may predate creation.
- Disposed Side Chat operations are fenced at every fork/send boundary; a late
Expand Down
Loading
Loading