diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index f6bee3f5ca..f9c38a3036 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -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, }) => { diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index bde0503b47..5cfb40123a 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -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'; @@ -66,8 +66,14 @@ let controllerRenderSnapshots: Array<{ terminalOwnerIds: Array; }> = []; -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: [ @@ -83,7 +89,7 @@ function ControllerProbe(props: UseWorkbarControllerInput) { function renderController( root: ReturnType['root'], services: WorkbarServices, - input: UseWorkbarControllerInput, + input: ControllerProbeInput, strictMode = false, ) { const probe = createElement( @@ -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')); diff --git a/apps/desktop/src/main/__tests__/workbar-model.test.ts b/apps/desktop/src/main/__tests__/workbar-model.test.ts index c60af2a802..5457866ff7 100644 --- a/apps/desktop/src/main/__tests__/workbar-model.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-model.test.ts @@ -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, @@ -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, bottomOpen: false, rightWidth: 480, bottomHeight: 300, @@ -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', @@ -201,7 +205,8 @@ describe('Workbar topology', () => { 'workbar:review', ), ), - rightCollapsed: false, + activeSessionId: 'session-a', + collapsedBySession: { 'session-a': false }, bottomOpen: true, rightWidth: 544, bottomHeight: 388, @@ -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({ diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 77a7fbad4f..6285df38d7 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -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 }> @@ -172,7 +173,6 @@ export function useWorkbarController( >(() => new Set()); const [, setLiveBrowserSessionIds] = useState([]); - const activeSessionId = input.activeSession?.id; const activeSessionIdRef = useRef(undefined); const resourceGenerationRef = useRef(0); useLayoutEffect(() => { diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-layout-state.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-layout-state.ts index af7b235da5..133b9d3f31 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-layout-state.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-layout-state.ts @@ -28,6 +28,7 @@ import { import type { ResizableProps } from '@astryxdesign/core/Resizable'; import { loadWorkbarLayout, + isSessionWorkbarCollapsed, persistWorkbarLayout, reduceWorkbarLayout, SESSION_BOTTOM_PANEL_MAX_HEIGHT, @@ -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 | 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); @@ -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'); @@ -207,7 +222,7 @@ export function useWorkbarLayoutState() { (next: SetStateAction) => { const collapsed = typeof next === 'function' - ? next(stateRef.current.rightCollapsed) + ? next(isSessionWorkbarCollapsed(stateRef.current)) : next; dispatch({ type: 'collapse', placement: 'right', collapsed }); }, @@ -227,7 +242,7 @@ export function useWorkbarLayoutState() { ); return { - workbarCollapsed: state.rightCollapsed, + workbarCollapsed: isSessionWorkbarCollapsed(state), setWorkbarCollapsed, bottomPanelOpen: state.bottomOpen, setBottomPanelOpen, diff --git a/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts b/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts index 75d112f40b..b4584098d2 100644 --- a/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts +++ b/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts @@ -44,7 +44,8 @@ export const SESSION_BOTTOM_PANEL_MAX_HEIGHT = 520; export interface WorkbarLayoutState { panels: SessionWorkbarPanelsState; - rightCollapsed: boolean; + activeSessionId: string | undefined; + collapsedBySession: Record; bottomOpen: boolean; rightWidth: number; bottomHeight: number; @@ -52,6 +53,8 @@ export interface WorkbarLayoutState { export type WorkbarLayoutAction = | WorkbarPanelsAction + | { type: 'activate-session'; sessionId: string | undefined } + | { type: 'retain-sessions'; sessionIds: ReadonlySet } | { type: 'collapse'; placement: 'right' | 'bottom'; @@ -85,11 +88,31 @@ export function readSessionWorkbarWidth(): number { return Number.isFinite(stored) && stored > 0 ? Math.round(stored) : SESSION_WORKBAR_DEFAULT_WIDTH; } -export function readSessionWorkbarCollapsed(): boolean { - const stored = safeLocalStorageGet('maka-session-workbar-collapsed-v1'); - if (stored === 'false') return false; - if (stored === 'true') return true; - return true; +const SESSION_COLLAPSE_KEY = 'maka-session-workbar-collapsed-v2'; + +function readSessionWorkbarCollapsed(): Record { + try { + const stored: unknown = JSON.parse(safeLocalStorageGet(SESSION_COLLAPSE_KEY) ?? '{}'); + if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {}; + return Object.fromEntries( + Object.entries(stored).filter(([, value]) => typeof value === 'boolean'), + ); + } catch { + return {}; + } +} + +export function isSessionWorkbarCollapsed(state: WorkbarLayoutState): boolean { + const id = state.activeSessionId; + return id !== undefined && Object.hasOwn(state.collapsedBySession, id) + ? state.collapsedBySession[id]! + : true; +} + +function withRightCollapsed(state: WorkbarLayoutState, collapsed: boolean): WorkbarLayoutState { + const id = state.activeSessionId; + if (id === undefined || isSessionWorkbarCollapsed(state) === collapsed) return state; + return { ...state, collapsedBySession: { ...state.collapsedBySession, [id]: collapsed } }; } export function readSessionBottomPanelHeight(): number { @@ -103,10 +126,11 @@ export function readSessionBottomPanelOpen(): boolean { return safeLocalStorageGet('maka-session-bottom-panel-open-v1') === 'true'; } -export function loadWorkbarLayout(): WorkbarLayoutState { +export function loadWorkbarLayout(activeSessionId?: string): WorkbarLayoutState { return { panels: readSessionWorkbarPanels(), - rightCollapsed: readSessionWorkbarCollapsed(), + activeSessionId, + collapsedBySession: readSessionWorkbarCollapsed(), bottomOpen: readSessionBottomPanelOpen(), rightWidth: clampSize( readSessionWorkbarWidth(), @@ -133,9 +157,16 @@ export function persistWorkbarLayout( } if (target === 'all' || target === 'right-visibility') { safeLocalStorageSet( - 'maka-session-workbar-collapsed-v1', - state.rightCollapsed ? 'true' : 'false', + SESSION_COLLAPSE_KEY, + JSON.stringify(state.collapsedBySession), ); + // The old global preference has no Session owner and cannot be migrated + // without giving an unrelated conversation its expanded state. + try { + localStorage.removeItem('maka-session-workbar-collapsed-v1'); + } catch { + // Storage may be unavailable in restricted renderer contexts. + } } if (target === 'all' || target === 'bottom-visibility') { safeLocalStorageSet( @@ -161,11 +192,22 @@ export function reduceWorkbarLayout( state: WorkbarLayoutState, action: WorkbarLayoutAction, ): WorkbarLayoutState { + if (action.type === 'activate-session') { + return state.activeSessionId === action.sessionId + ? state + : { ...state, activeSessionId: action.sessionId }; + } + if (action.type === 'retain-sessions') { + const entries = Object.entries(state.collapsedBySession).filter( + ([id]) => id === state.activeSessionId || action.sessionIds.has(id), + ); + return entries.length === Object.keys(state.collapsedBySession).length + ? state + : { ...state, collapsedBySession: Object.fromEntries(entries) }; + } if (action.type === 'collapse') { if (action.placement === 'right') { - return state.rightCollapsed === action.collapsed - ? state - : { ...state, rightCollapsed: action.collapsed }; + return withRightCollapsed(state, action.collapsed); } const bottomOpen = !action.collapsed; return state.bottomOpen === bottomOpen @@ -195,7 +237,7 @@ export function reduceWorkbarLayout( const panels = reduceWorkbarPanels(state.panels, action); if (panels === state.panels) return state; - let rightCollapsed = state.rightCollapsed; + let rightCollapsed = isSessionWorkbarCollapsed(state); let bottomOpen = state.bottomOpen; if (action.type === 'open' || action.type === 'open-launcher') { if (action.placement === 'right') rightCollapsed = false; @@ -212,5 +254,5 @@ export function reduceWorkbarLayout( else bottomOpen = false; } } - return { ...state, panels, rightCollapsed, bottomOpen }; + return withRightCollapsed({ ...state, panels, bottomOpen }, rightCollapsed); } diff --git a/apps/desktop/src/renderer/live-turn-snapshot.ts b/apps/desktop/src/renderer/live-turn-snapshot.ts index 229fc8a8cc..8c33dc7eb3 100644 --- a/apps/desktop/src/renderer/live-turn-snapshot.ts +++ b/apps/desktop/src/renderer/live-turn-snapshot.ts @@ -109,7 +109,12 @@ export function selectStreamingSessionIds( return streaming; } -export function sessionIdSetsEqual(a: ReadonlySet, b: ReadonlySet): boolean { +export function sessionIdSetsEqual( + a: ReadonlySet | undefined, + b: ReadonlySet | undefined, +): boolean { + if (a === b) return true; + if (!a || !b) return false; if (a.size !== b.size) return false; for (const id of a) { if (!b.has(id)) return false; diff --git a/apps/desktop/src/renderer/session-catalog-state.ts b/apps/desktop/src/renderer/session-catalog-state.ts index 294a73beef..873f3afb67 100644 --- a/apps/desktop/src/renderer/session-catalog-state.ts +++ b/apps/desktop/src/renderer/session-catalog-state.ts @@ -78,8 +78,11 @@ export const selectActiveSessionId = (state: SessionCatalogState): string | unde * selection would re-render every reader that only cares about which sessions * exist. */ -export const selectAuthoritativeSessionIds = (state: SessionCatalogState): ReadonlySet => - new Set(state.sessions.map(({ id }) => id)); +export const selectAuthoritativeSessionIds = ( + state: SessionCatalogState, +): ReadonlySet | undefined => + // The initial empty catalog cannot prove that persisted Sessions were deleted. + state.revision > 0 ? new Set(state.sessions.map(({ id }) => id)) : undefined; /** * Owns the controller for the component's lifetime. Deliberately does NOT