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
21 changes: 21 additions & 0 deletions apps/desktop/e2e/session-workbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,27 @@ async function createSession(page: Page, prompt: string) {
return { composer, sessionId: sessionId!, sidebar };
}

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 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
54 changes: 50 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,46 @@ 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('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 @@ -201,7 +205,8 @@ describe('Workbar topology', () => {
'workbar:review',
),
),
rightCollapsed: false,
activeSessionId: 'session-a',
collapsedBySession: { 'session-a': false },
bottomOpen: true,
rightWidth: 544,
bottomHeight: 388,
Expand All @@ -220,20 +225,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 @@ -157,7 +157,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 @@ -172,7 +173,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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import type { ResizableProps } from '@astryxdesign/core/Resizable';
import {
loadWorkbarLayout,
isSessionWorkbarCollapsed,
persistWorkbarLayout,
reduceWorkbarLayout,
SESSION_BOTTOM_PANEL_MAX_HEIGHT,
Expand All @@ -45,14 +46,28 @@ const LAYOUT_PERSIST_DEBOUNCE_MS = 200;

/**
* Owns the application-level Workbar topology, dimensions and persistence.
* Session-owned panel data deliberately lives below this boundary.
* Right-panel visibility belongs to each Session; topology and sizes stay global.
*/
export function useWorkbarLayoutState() {
export function useWorkbarLayoutState(
activeSessionId: string | undefined,
authoritativeSessionIds: ReadonlySet<string> | undefined,
) {
const [state, dispatch] = useReducer(
reduceWorkbarLayout,
undefined,
activeSessionId,
loadWorkbarLayout,
);
// Bind the owner before this render commits. An effect-based mirror would
// briefly show the previous Session's panel and could overwrite an open
// action issued by another layout effect in the activation commit.
if (state.activeSessionId !== activeSessionId) {
dispatch({ type: 'activate-session', sessionId: activeSessionId });
}
useEffect(() => {
if (authoritativeSessionIds) {
dispatch({ type: 'retain-sessions', sessionIds: authoritativeSessionIds });
}
}, [authoritativeSessionIds, activeSessionId]);
const stateRef = useRef(state);
stateRef.current = state;
const rightDragStartRef = useRef(state.rightWidth);
Expand Down Expand Up @@ -189,7 +204,7 @@ export function useWorkbarLayoutState() {
}, [state.rightWidth]);
useEffect(() => {
persistWorkbarLayout(stateRef.current, 'right-visibility');
}, [state.rightCollapsed]);
}, [state.collapsedBySession]);
useEffect(() => {
const handle = window.setTimeout(() => {
persistWorkbarLayout(stateRef.current, 'bottom-size');
Expand All @@ -207,7 +222,7 @@ export function useWorkbarLayoutState() {
(next: SetStateAction<boolean>) => {
const collapsed =
typeof next === 'function'
? next(stateRef.current.rightCollapsed)
? next(isSessionWorkbarCollapsed(stateRef.current))
: next;
dispatch({ type: 'collapse', placement: 'right', collapsed });
},
Expand All @@ -227,7 +242,7 @@ export function useWorkbarLayoutState() {
);

return {
workbarCollapsed: state.rightCollapsed,
workbarCollapsed: isSessionWorkbarCollapsed(state),
setWorkbarCollapsed,
bottomPanelOpen: state.bottomOpen,
setBottomPanelOpen,
Expand Down
Loading