Skip to content
Merged
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
29 changes: 29 additions & 0 deletions apps/desktop/e2e/session-workbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,35 @@ test('the composer usage action opens Task trace in the right workbar', async ({
).toBeVisible();
});

test('right workbar visibility belongs to each Session and survives reload', async ({
window: page,
}) => {
const first = await createSession(page, 'first workbar owner');
const panel = page.locator('.maka-session-workbar[data-placement="right"]');
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await page
.getByRole('list', { name: '打开工具' })
.getByRole('button', { name: /变更.*查看当前 Git 工作区变化/ })
.click();
await page.getByRole('button', { name: '打开或关闭工作栏的面' }).click();
await page.getByRole('menu').getByRole('menuitem', { name: '追踪', exact: true }).click();
await expect(panel).toBeVisible();
await first.sidebar.getByRole('button', { name: '新任务', exact: true }).click();
const second = await createSession(page, 'second workbar owner');
await expect(panel).toBeHidden();
await first.sidebar.locator(`[data-session-id=${JSON.stringify(first.sessionId)}]`).click();
await expect(panel).toBeVisible();
await page.reload();
await expect(page.locator(COMPOSER_INPUT)).toBeVisible();
const sidebar = page.getByRole('navigation', { name: '任务列表' });
const expandSidebar = page.getByRole('button', { name: '展开侧边栏' });
if (await expandSidebar.isVisible()) await expandSidebar.click();
await sidebar.locator(`[data-session-id=${JSON.stringify(first.sessionId)}]`).click();
await expect(panel).toBeVisible();
await sidebar.locator(`[data-session-id=${JSON.stringify(second.sessionId)}]`).click();
await expect(panel).toBeHidden();
});

test('a collapsed workbar never flashes during the first send', async ({
window: page,
}) => {
Expand Down
81 changes: 77 additions & 4 deletions apps/desktop/src/main/__tests__/workbar-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import { deferred } from '@maka/core/test-only/async-primitives';
import { strict as assert } from 'node:assert';
import { afterEach, describe, it } from 'node:test';
import { act, createElement, StrictMode } from 'react';
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';
Expand Down Expand Up @@ -66,8 +66,14 @@ let controllerRenderSnapshots: Array<{
terminalOwnerIds: Array<string | undefined>;
}> = [];

function ControllerProbe(props: UseWorkbarControllerInput) {
latestController = useWorkbarController(props);
type ControllerProbeInput = UseWorkbarControllerInput & { openOnActivation?: boolean };

function ControllerProbe(props: ControllerProbeInput) {
const workbar = useWorkbarController(props);
latestController = workbar;
useLayoutEffect(() => {
if (props.openOnActivation) workbar.host.onOpenLauncher('right');
}, [props.activeSession?.id, props.openOnActivation]);
controllerRenderSnapshots.push({
activeId: latestController.host.activeId,
terminalOwnerIds: [
Expand All @@ -83,7 +89,7 @@ function ControllerProbe(props: UseWorkbarControllerInput) {
function renderController(
root: ReturnType<typeof installReactRenderer>['root'],
services: WorkbarServices,
input: UseWorkbarControllerInput,
input: ControllerProbeInput,
strictMode = false,
) {
const probe = createElement(
Expand Down Expand Up @@ -133,6 +139,73 @@ describe('useWorkbarController', () => {
delete (globalThis as { window?: unknown }).window;
});

it('keeps right-panel visibility independent across Session navigation', async () => {
const { root } = installReactRenderer();
const services = createFakeWorkbarServices();
const authoritativeSessionIds = new Set(['a', 'b']);
const show = (id: string | undefined) => renderController(root, services, {
...input(id ? session(id) : undefined),
authoritativeSessionIds,
});

await act(async () => show('a'));
await act(async () => controller().commands.toggleRight());
assert.equal(controller().host.rightCollapsed, false);
await act(async () => show(undefined));
await act(async () => show('b'));
assert.equal(controller().host.rightCollapsed, true);
await act(async () => show('a'));
assert.equal(controller().host.rightCollapsed, false);
});

it('keeps an open requested in the activation commit bound to the new Session', async () => {
const { root } = installReactRenderer();
const services = createFakeWorkbarServices();
const authoritativeSessionIds = new Set(['a', 'b']);
await act(async () => renderController(root, services, {
...input(session('a')), authoritativeSessionIds,
}, true));
await act(async () => renderController(root, services, {
...input(session('b')), authoritativeSessionIds, openOnActivation: true,
}, true));
assert.equal(controller().host.rightCollapsed, false);
await act(async () => renderController(root, services, {
...input(session('a')), authoritativeSessionIds,
}, true));
assert.equal(controller().host.rightCollapsed, true);
await act(async () => renderController(root, services, {
...input(session('b')), authoritativeSessionIds,
}, true));
assert.equal(controller().host.rightCollapsed, false);
});

it("preserves the active Session's visibility while removing the previous Session's Terminal", async () => {
const { root } = installReactRenderer();
const defaults = createFakeWorkbarServices();
const services = createFakeWorkbarServices({
terminal: {
...defaults.terminal,
start: async (sessionId) => shellUpdate(sessionId, 'terminal-a'),
},
});
const authoritativeSessionIds = new Set(['a', 'b']);
const show = (id: string) => renderController(root, services, {
...input(session(id)),
authoritativeSessionIds,
});

await act(async () => show('b'));
await act(async () => controller().commands.toggleRight());
assert.equal(controller().host.rightCollapsed, false);
await act(async () => show('a'));
await act(async () => controller().commands.openTool('terminal'));
assert.equal(controller().host.panelsState.right.tabs.length, 1);
await act(async () => show('b'));

assert.equal(controller().host.panelsState.right.tabs.length, 0);
assert.equal(controller().host.rightCollapsed, false);
});

it('projects the canonical project and absorbed aliases into the host model', async () => {
const { root } = installReactRenderer();
const controllerInput = input(session('a'));
Expand Down
68 changes: 63 additions & 5 deletions apps/desktop/src/main/__tests__/workbar-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@
* under the License.
*/

import { createSessionCatalogController, selectAuthoritativeSessionIds } from '../../renderer/session-catalog-state.js';
import { sessionIdSetsEqual } from '../../renderer/live-turn-snapshot.js';
import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';
import {
createSessionWorkbarPanelsState,
createSessionWorkbarTabsState,
loadWorkbarLayout,
isSessionWorkbarCollapsed,
persistWorkbarLayout,
persistableSessionWorkbarPanels,
readSessionWorkbarPanels,
Expand Down Expand Up @@ -89,7 +92,8 @@ describe('Workbar topology', () => {
it('routes panel visibility and dimensions through the layout reducer', () => {
let state = {
panels: createSessionWorkbarPanelsState(),
rightCollapsed: true,
activeSessionId: 'session-a' as string | undefined,
collapsedBySession: {} as Record<string, boolean>,
bottomOpen: false,
rightWidth: 480,
bottomHeight: 300,
Expand All @@ -99,7 +103,7 @@ describe('Workbar topology', () => {
placement: 'right',
tab: { id: 'workbar:review', kind: 'review' },
});
assert.equal(state.rightCollapsed, false);
assert.equal(isSessionWorkbarCollapsed(state), false);
state = reduceWorkbarLayout(state, {
type: 'resize',
placement: 'right',
Expand Down Expand Up @@ -229,7 +233,8 @@ describe('Workbar topology', () => {
'workbar:review',
),
),
rightCollapsed: false,
activeSessionId: 'session-a',
collapsedBySession: { 'session-a': false },
bottomOpen: true,
rightWidth: 544,
bottomHeight: 388,
Expand All @@ -248,20 +253,73 @@ describe('Workbar topology', () => {
focusedPanel: 'right',
},
);
assert.deepEqual(loadWorkbarLayout(), {
assert.deepEqual(loadWorkbarLayout('session-a'), {
panels: createSessionWorkbarPanelsState(
createSessionWorkbarTabsState(
[{ id: 'workbar:review', kind: 'review' }],
'workbar:review',
),
),
rightCollapsed: false,
activeSessionId: 'session-a',
collapsedBySession: { 'session-a': false },
bottomOpen: true,
rightWidth: 544,
bottomHeight: 388,
});
});

it('persists per-Session collapse and retires the ownerless global preference', () => {
cleanups.push(installMemoryLocalStorage({ 'maka-session-workbar-collapsed-v1': 'false' }));
let state = loadWorkbarLayout('a');
assert.equal(isSessionWorkbarCollapsed(state), true);
state = reduceWorkbarLayout(state, { type: 'collapse', placement: 'right', collapsed: false });
state = reduceWorkbarLayout(state, { type: 'activate-session', sessionId: 'b' });
assert.equal(isSessionWorkbarCollapsed(state), true);
persistWorkbarLayout(state, 'right-visibility');
assert.equal(localStorage.getItem('maka-session-workbar-collapsed-v1'), null);
assert.equal(isSessionWorkbarCollapsed(loadWorkbarLayout('a')), false);
assert.equal(isSessionWorkbarCollapsed(loadWorkbarLayout('b')), true);
assert.equal(isSessionWorkbarCollapsed(loadWorkbarLayout()), true);
});

it('distinguishes an unhydrated catalog from an authoritative empty snapshot', () => {
const catalog = createSessionCatalogController();
const pending = selectAuthoritativeSessionIds(catalog.getState());
assert.equal(pending, undefined);
catalog.commitSessions([]);
const empty = selectAuthoritativeSessionIds(catalog.getState());
assert.deepEqual(empty, new Set());
assert.equal(sessionIdSetsEqual(pending, empty), false);
assert.equal(sessionIdSetsEqual(empty, pending), false);
assert.equal(sessionIdSetsEqual(pending, pending), true);
assert.equal(sessionIdSetsEqual(empty, new Set()), true);
});

it('evicts deleted Sessions without dropping an active Session awaiting catalog hydration', () => {
cleanups.push(installMemoryLocalStorage({
'maka-session-workbar-collapsed-v2': JSON.stringify({ a: false, b: false, deleted: false }),
}));
let state = loadWorkbarLayout('a');
state = reduceWorkbarLayout(state, { type: 'retain-sessions', sessionIds: new Set(['b']) });
assert.deepEqual(state.collapsedBySession, { a: false, b: false });
state = reduceWorkbarLayout(state, { type: 'activate-session', sessionId: 'b' });
state = reduceWorkbarLayout(state, { type: 'retain-sessions', sessionIds: new Set(['b']) });
persistWorkbarLayout(state, 'right-visibility');
assert.deepEqual(loadWorkbarLayout().collapsedBySession, { b: false });
});

it('ignores malformed collapse entries and treats prototype names as Session keys', () => {
cleanups.push(installMemoryLocalStorage({
'maka-session-workbar-collapsed-v2': '{"a":"false","b":false,"__proto__":false}',
}));
assert.equal(isSessionWorkbarCollapsed(loadWorkbarLayout('a')), true);
assert.equal(isSessionWorkbarCollapsed(loadWorkbarLayout('b')), false);
assert.equal(isSessionWorkbarCollapsed(loadWorkbarLayout('__proto__')), false);
assert.equal(isSessionWorkbarCollapsed(loadWorkbarLayout('constructor')), true);
localStorage.setItem('maka-session-workbar-collapsed-v2', '{broken');
assert.deepEqual(loadWorkbarLayout().collapsedBySession, {});
});

it('falls back to an empty topology for corrupt v3 storage', () => {
cleanups.push(
installMemoryLocalStorage({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ export function useWorkbarController(
const locale = useUiLocale();
const terminalCopy = getDesktopConversationCopy(locale).terminalPanel;
const { browser, sideChat, terminal } = useWorkbarServices();
const layout = useWorkbarLayoutState();
const activeSessionId = input.activeSession?.id;
const layout = useWorkbarLayoutState(activeSessionId, input.authoritativeSessionIds);
const sideConversations = useSideConversationWorkspace();
const [pendingSideChatClose, setPendingSideChatClose] = useState<
Array<{ placement: SessionWorkbarPlacement; tab: SessionWorkbarTab }>
Expand All @@ -183,7 +184,6 @@ export function useWorkbarController(
>(() => new Set());
const [, setLiveBrowserSessionIds] = useState<readonly string[]>([]);

const activeSessionId = input.activeSession?.id;
const activeSessionIdRef = useRef<string | undefined>(undefined);
const resourceGenerationRef = useRef(0);
useLayoutEffect(() => {
Expand Down Expand Up @@ -449,6 +449,7 @@ export function useWorkbarController(
(
placement: SessionWorkbarPlacement,
tabs: readonly SessionWorkbarTab[],
options?: { preserveVisibility?: boolean },
) => {
if (tabs.length === 0) return;
for (const tab of tabs) {
Expand All @@ -458,6 +459,7 @@ export function useWorkbarController(
layout.closeWorkbarTabs(
placement,
tabs.map((tab) => tab.id),
options,
);
const panelIds = new Set(
tabs
Expand Down Expand Up @@ -540,6 +542,7 @@ export function useWorkbarController(
stale
.filter((candidate) => candidate.placement === placement)
.map((candidate) => candidate.tab),
{ preserveVisibility: true },
);
}
}, [
Expand All @@ -566,12 +569,14 @@ export function useWorkbarController(
)
? 'right'
: 'bottom';
layout.closeWorkbarTab(placement, tabId);
layout.closeWorkbarTabs(placement, [tabId], {
preserveVisibility: true,
});
}
sideConversations.removePanels(staleIds);
}, [
activeSessionId,
layout.closeWorkbarTab,
layout.closeWorkbarTabs,
layout.workbarPanelsState,
sideConversations.panels,
sideConversations.removePanels,
Expand Down
Loading