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
35 changes: 26 additions & 9 deletions apps/desktop/e2e/session-workbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import { awaitSendReady, COMPOSER_INPUT, test, expect } from './fixtures';
import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend';
import type { Page } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
Expand Down Expand Up @@ -247,13 +248,16 @@ test('Terminal survives navigation and reload, then stops on explicit close', as
await expect(terminal).toHaveCount(0);
});

test('Side Chat survives collapse, confirms close, and cleans up on source switch', async ({
test('Side Chat survives collapse and source switches, then cleans up on explicit close', async ({
window: page,
}) => {
const { composer, sessionId, sidebar } = await createSession(
const { sessionId, sidebar } = await createSession(
page,
'create side chat source session',
);
await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
const other = await createSession(page, 'create another main session');
await sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await page.getByRole('button', { name: '展开任务工作栏' }).click();
const openSideChat = page.getByRole('button', {
name: /侧边对话.*在不打断主任务的情况下追问和只读探索/,
Expand Down Expand Up @@ -309,22 +313,35 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
await openSideChat.click();
await expect(companion).toBeVisible();
// Fork again on the reopened panel's first send.
// Switch away immediately after the first send starts. This keeps creation
// and execution in flight across the exact navigation race that used to
// classify the panel as stale and delete its temporary fork.
const reopenedComposer = companion.locator(COMPOSER_INPUT);
await reopenedComposer.fill('inspect once more before switching away');
await reopenedComposer.fill(FAKE_HOLD_OPEN_PROMPT);
await awaitSendReady(companion);
await reopenedComposer.press('Enter');
await expect(companion).toContainText(
'Fake backend received: inspect once more before switching away',
);
await sidebar.locator(`[data-session-id=${JSON.stringify(other.sessionId)}]`).click();
await expect(companion).toBeAttached();
await expect(companion).not.toBeVisible();
const secondForkId = await waitForCompanionForkId(page, sessionId);
await expect
.poll(async () =>
(await page.evaluate(() => window.maka.sessions.list()))
.some((session) => session.id === secondForkId),
)
.toBe(true);

await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
await sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await expect(companion).toBeVisible();
await expect(companion).toContainText('Fake backend waiting');

await closeActiveSideChat();
await confirmation.getByRole('button', { name: '关闭侧边对话' }).click();
await expect(companion).toHaveCount(0);
await expect
.poll(async () =>
(await page.evaluate(() => window.maka.sessions.list()))
.some((session) => session.id === secondForkId),
)
.toBe(false);
await expect(composer).toHaveText('');
});
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ const linkedCatalog = [
profileName: 'Ubuntu',
profileKind: 'environment',
}),
session('side-conversation', {
parentSessionId: 'root',
labels: ['mode:side_conversation'],
}),
session('archived', { isArchived: true }),
session('hidden'),
];
Expand Down Expand Up @@ -205,7 +209,7 @@ describe('useSessionNavigationReads', () => {
latestReads = undefined;
});

it('projects linked, archived, hidden, Project, and Runtime Host Sessions once', async () => {
it('projects linked, archived, hidden, side-conversation, Project, and Runtime Host Sessions once', async () => {
const { root } = installReactRenderer();
await act(async () =>
root.render(
Expand Down
97 changes: 95 additions & 2 deletions apps/desktop/src/main/__tests__/workbar-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,24 @@ describe('useWorkbarController', () => {
assert.equal(controller().host.panelsState.bottom.activeTabId, 'workbar:inspector');
});

it('opens a Side Chat for the active Session instead of toggling a hidden one', async () => {
const { root } = installReactRenderer();
const services = createFakeWorkbarServices();
const authoritativeSessionIds = new Set(['a', 'b']);
const show = (id: string) => renderController(root, services, {
...input(session(id)),
authoritativeSessionIds,
});

await act(async () => show('a'));
await act(async () => controller().commands.toggleTool('side-chat'));
assert.deepEqual(controller().host.quotes?.map((panel) => panel.sourceSessionId), ['a']);

await act(async () => show('b'));
await act(async () => controller().commands.toggleTool('side-chat'));
assert.deepEqual(controller().host.quotes?.map((panel) => panel.sourceSessionId), ['a', 'b']);
});

it('keeps right-panel visibility independent across Session navigation', async () => {
const { root } = installReactRenderer();
const services = createFakeWorkbarServices();
Expand Down Expand Up @@ -957,7 +975,7 @@ describe('useWorkbarController', () => {
assert.deepEqual(staleErrors, []);
});

it('keeps Side Chat through collapse, confirms content close, and removes it on source switch', async () => {
it('keeps Side Chat through collapse and source switches, but confirms explicit content close', async () => {
const { root } = installReactRenderer();
const services = createFakeWorkbarServices();
await act(async () => renderController(root, services, input(session('a'))));
Expand Down Expand Up @@ -993,13 +1011,88 @@ describe('useWorkbarController', () => {
);

await act(async () => controller().commands.openTool('side-chat'));
const retainedPanelId = controller().host.quotes?.[0]?.id;
assert.ok(retainedPanelId);
await act(async () => renderController(root, services, input(session('b'))));
assert.equal(
controller().host.panelsState.right.tabs.some(
(candidate) => candidate.kind === 'side-chat',
(candidate) => candidate.id === `side-chat:${retainedPanelId}`,
),
true,
);
assert.equal(
controller().host.quotes?.some((panel) => panel.id === retainedPanelId),
true,
);
await act(async () => renderController(root, services, input(session('a'))));
assert.equal(
controller().host.quotes?.some((panel) => panel.id === retainedPanelId),
true,
);
});

it('retains a Side Chat through a catalog gap and archive, then retires it on source deletion', async () => {
const { root } = installReactRenderer();
const defaults = createFakeWorkbarServices();
const sessionChangeHandlers = new Set<Parameters<WorkbarServices['sideChat']['subscribeSessionChanges']>[0]>();
const services = createFakeWorkbarServices({ sideChat: {
...defaults.sideChat,
subscribeSessionChanges: (handler) => {
sessionChangeHandlers.add(handler);
return () => { sessionChangeHandlers.delete(handler); };
},
} });
const show = (id: string, authoritativeSessionIds: ReadonlySet<string>) =>
renderController(root, services, {
...input(session(id)),
authoritativeSessionIds,
});

await act(async () => show('a', new Set(['a', 'b'])));
await act(async () => controller().commands.openTool('side-chat'));
const panelId = controller().host.quotes?.[0]?.id;
assert.ok(panelId);
await act(async () => controller().host.onContentStateChange?.(panelId, true));

await act(async () => show('b', new Set(['a', 'b'])));
await act(async () => controller().commands.toggleRight());
assert.equal(controller().host.rightCollapsed, false);
assert.equal(controller().host.quotes?.[0]?.sourceSessionId, 'a');

await act(async () => show('b', new Set(['b'])));
assert.equal(
controller().host.panelsState.right.tabs.some(
(tab) => tab.id === `side-chat:${panelId}`,
),
true,
);
assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true);
await act(async () => {
for (const handler of sessionChangeHandlers) handler({ reason: 'archived', sessionId: 'a', ts: Date.now() });
});
assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true);

await act(async () => show('b', new Set(['a', 'b'])));
assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true);
await act(async () => {
for (const handler of sessionChangeHandlers) handler({ reason: 'deleted', sessionId: 'b', ts: Date.now() });
});
assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true);
await act(async () => {
for (const handler of sessionChangeHandlers) handler({ reason: 'deleted', sessionId: 'a', ts: Date.now() });
});
assert.equal(
controller().host.panelsState.right.tabs.some(
(tab) => tab.id === `side-chat:${panelId}`,
),
false,
);
assert.equal(
controller().host.quotes?.some((panel) => panel.id === panelId),
false,
);
assert.equal(controller().host.closeConfirmation.open, false);
assert.equal(controller().host.rightCollapsed, false);
});

it('keeps a newly created companion hidden through panel changes and stale catalogs until cleanup', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@
*/

import type { SessionSummary } from '@maka/core/session';
import { isSideConversationSession } from '@maka/core/side-conversation';

/**
* Which sessions the rail lists. Archived tasks are managed in Settings › 活动 ›
* 已归档任务 (#2985), so the rail shows everything else.
* 已归档任务 (#2985). Side-conversation forks belong to their Workbar panels,
* not the main task catalog; filtering their durable label here prevents the
* `sessions:changed(created)` broadcast from flashing a row before the panel's
* renderer-local hidden-id update arrives.
*
* This used to switch on `NavSelection.filter`. That filter is gone (#2984): its
* last two values were a destination that moved to Settings and a value nothing
* ever selected, which left one branch reachable — this one.
*/
export function sessionMatchesRail(session: SessionSummary): boolean {
return !session.isArchived;
return !session.isArchived && !isSideConversationSession(session.labels);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import {
useCallback,
useEffect,
useEffectEvent,
useLayoutEffect,
useMemo,
useRef,
Expand Down Expand Up @@ -682,6 +683,45 @@ export function useWorkbarController(
[layout.closeWorkbarTabs, sideConversations, terminal, input.toastApi, terminalCopy.stopFailed, locale],
);

const retireDeletedSessionSideChats = useEffectEvent((sourceSessionId: string) => {
const retiredPanelIds = new Set(
sideConversations.panels
.filter((panel) => panel.sourceSessionId === sourceSessionId)
.map((panel) => panel.id),
);
if (retiredPanelIds.size === 0) return;
setPendingSideChatClose((current) => {
const retained = current.filter(
({ tab }) =>
tab.kind !== 'side-chat' ||
!retiredPanelIds.has(tab.id.slice('side-chat:'.length)),
);
return retained.length === current.length ? current : retained;
});
for (const placement of ['right', 'bottom'] as const) {
const tabs = panelsStateRef.current[placement].tabs.filter(
(tab) =>
tab.kind === 'side-chat' &&
retiredPanelIds.has(tab.id.slice('side-chat:'.length)),
);
closeTabsWithoutConfirmation(placement, tabs, {
preserveVisibility: true,
});
}
// Dropping the quote unmounts QuoteCompanionPanel, which runs the same
// durable fork cleanup as an explicit close. An orphan record without a
// matching tab must take that path too.
sideConversations.removePanels(retiredPanelIds);
});

useEffect(() => sideChat.subscribeSessionChanges((event) => {
// A catalog refresh can omit a still-live source temporarily. Only the
// Host's committed deletion signal may destroy its ephemeral fork.
if (event.reason === 'deleted' && event.sessionId) {
retireDeletedSessionSideChats(event.sessionId);
}
}), [sideChat]);

const closeTabs = useCallback(
(
placement: SessionWorkbarPlacement,
Expand Down Expand Up @@ -736,32 +776,6 @@ export function useWorkbarController(
setPendingSideChatClose([]);
}, [activeSessionId]);

useLayoutEffect(() => {
const stalePanels = sideConversations.panels.filter(
(panel) => panel.sourceSessionId !== activeSessionId,
);
if (stalePanels.length === 0) return;
const staleIds = new Set(stalePanels.map((panel) => panel.id));
for (const panel of stalePanels) {
const tabId = `side-chat:${panel.id}`;
const placement = layout.workbarPanelsState.right.tabs.some(
(tab) => tab.id === tabId,
)
? 'right'
: 'bottom';
layout.closeWorkbarTabs(placement, [tabId], {
preserveVisibility: true,
});
}
sideConversations.removePanels(staleIds);
}, [
activeSessionId,
layout.closeWorkbarTabs,
layout.workbarPanelsState,
sideConversations.panels,
sideConversations.removePanels,
]);

const companionRecoveryStartedRef = useRef(false);
useLayoutEffect(() => {
if (companionRecoveryStartedRef.current) return;
Expand Down Expand Up @@ -799,12 +813,23 @@ export function useWorkbarController(

const toggleTool = useCallback((kind: SessionWorkbarTabKind) => {
if (!workbarToolsForWorkspace(workspace).some((tool) => tool.kind === kind)) return;
const activeSideChatTabIds = kind === 'side-chat'
? new Set(
sideConversations.panels
.filter((panel) => panel.sourceSessionId === activeSessionIdRef.current)
.map((panel) => `side-chat:${panel.id}`),
)
: undefined;
const matchesTool = (candidate: SessionWorkbarTab) =>
candidate.kind === kind &&
(!activeSideChatTabIds || activeSideChatTabIds.has(candidate.id));
const panels = panelsStateRef.current;
const placements = [panels.focusedPanel, 'right', 'bottom'] as const;
for (const placement of placements) {
const panel = panels[placement];
const tab = panel.tabs.find((candidate) => candidate.id === panel.activeTabId && candidate.kind === kind)
?? panel.tabs.find((candidate) => candidate.kind === kind);
const tab = panel.tabs.find(
(candidate) => candidate.id === panel.activeTabId && matchesTool(candidate),
) ?? panel.tabs.find(matchesTool);
if (!tab) continue;
const visible = placement === 'right' ? !layout.workbarCollapsed : layout.bottomPanelOpen;
if (visible && !panel.launcherOpen && panel.activeTabId === tab.id) {
Expand All @@ -817,8 +842,9 @@ export function useWorkbarController(
return;
}
openTool(kind);
}, [workspace, layout.workbarCollapsed, layout.bottomPanelOpen, layout.setWorkbarCollapsed,
layout.setBottomPanelOpen, layout.activateWorkbarTab, revealPlacement, openTool]);
}, [workspace, sideConversations.panels, layout.workbarCollapsed, layout.bottomPanelOpen,
layout.setWorkbarCollapsed, layout.setBottomPanelOpen, layout.activateWorkbarTab,
revealPlacement, openTool]);

useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => {
Expand Down Expand Up @@ -927,9 +953,11 @@ export function useWorkbarController(
},
rightResizable: layout.workbarResizable,
bottomResizable: layout.bottomPanelResizable,
quotes: sideConversations.panels.filter(
(panel) => panel.sourceSessionId === activeSessionId,
),
// Keep every Side Chat mounted while another main Session is selected.
// WorkbarSurface projects only the active Session's tabs, but retaining
// the inactive panels preserves their hook state and prevents an ordinary
// navigation from running the explicit-dismiss cleanup path.
quotes: sideConversations.panels,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Scope the Side Chat toggle to the active Session

After this change, sideConversations.panels retains Session A's panel globally, but toggleTool('side-chat') still scans the unprojected panelsStateRef.current. Open a Side Chat in A, switch to B, then press the documented primary+Alt+S shortcut: the toggle finds A's hidden tab, activates it, and returns. WorkbarSurface filters that tab out for B, so nothing becomes visible and no B Side Chat is created. A production-controller probe on this head leaves the panel sources as ['a']; the same assertion on exact base creates B as expected (['b']). Please filter Side Chat candidates by the active source Session, or fall through to openTool, before returning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is a real regression. toggleTool was searching the retained global Side Chat tabs while WorkbarSurface projected them by source Session, so it could activate a hidden tab and return. Fixed in 87ddd7fc6 by restricting Side Chat toggle candidates to panels whose sourceSessionId matches the active Session, which falls through to opening a new Side Chat when no current-Session candidate exists. Added an A → B controller regression test; the focused Workbar/navigation suites pass 40/40, along with Desktop build:test, renderer typecheck, Biome, and git diff --check.

onQuotesConsumed: (snapshot) =>
sideConversations.updatePanel(snapshot.panelId, (panel) =>
consumeCompanionQuoteSnapshot(panel, snapshot) ?? panel,
Expand Down
Loading