diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml index 5ae814d291..4cb3d0e859 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-collapsed-sidebar-control-rail.md: 27e7d0e18a20b2ea6de8128f9c3518279ec21aca -2026-07-22-collapsed-sidebar-control-rail.zh.md: bfbb5230f952e49960142bb5ce090dc6753487ec +2026-07-22-collapsed-sidebar-control-rail.md: 9f244010a1ec14eeafe707aedfffcf7e5bbe7736 +2026-07-22-collapsed-sidebar-control-rail.zh.md: 53007bf717404b151d0a2d1f673c20f111ee8234 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md index 27e7d0e18a..9f244010a1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -6,15 +6,15 @@ English | [中文](2026-07-22-collapsed-sidebar-control-rail.zh.md) ## Problem -The sidebar close action persisted `open: false`, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout. +The sidebar close action persisted a zero width preference, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout. ## Decision -The layout maps a closed sidebar to the fixed `SIDEBAR_COLLAPSED` width of 60px: one 28px icon control between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. +The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 60px: one 28px icon control between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. -`AppFrame` marks the sidebar collapsed from the persisted `open` preference rather than from a zero resolved width. It keeps the sidebar slot mounted but removes the resize handle while collapsed. +`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. -`SidebarRoot` subscribes to the derived open boolean. Its collapsed render removes the brand, creation controls, search, and session tree from the rendered and accessibility trees; the top control changes to `Expand sidebar`, and the bottom `Settings` control remains in the rail. +`SidebarRoot` reads the owner `collapsed` prop. Its collapsed render removes the brand, creation controls, search, and session tree from the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription; the top control changes to `Expand sidebar`, and the bottom `Settings` control remains in the rail. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md index bfbb5230f9..53007bf717 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -侧边栏关闭操作会持久化 `open: false`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。 +侧边栏关闭操作会持久化宽度偏好 `0`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。 ## 决策 -布局将关闭的侧边栏映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 +布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 -`AppFrame` 根据持久化的 `open` 偏好标记侧边栏是否折叠,而不是根据求解后的宽度是否为零来判断。它让侧边栏插槽保持挂载,但在折叠时移除尺寸调整手柄。 +`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。 -`SidebarRoot` 订阅派生的布尔型打开状态。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。 +`SidebarRoot` 读取 owner 的 `collapsed` 属性。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除——主体组件卸载,随之退订会话列表;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index b75716c615..81b41c4d8c 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,9 +1,9 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real // chromium. First describe: manifest injection + fail-loud half. Second -// describe: the settled success pass — five REAL tsdown bundles (the -// infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation +// describe: the settled success pass — six REAL tsdown bundles (the +// infrastructure four + layout/sidebar) load through the DI chain in ?fixture +// mode and the three-column frame appears in one flip. The full conversation // round lands in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -17,13 +17,14 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass (immediately four + layout/sidebar). */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ @@ -91,7 +92,7 @@ describe('web boot chain (keyless, real carrier)', () => { }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, six real bundles, ?fixture)', () => { const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -141,6 +142,21 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( const owners = await page.evaluate(() => [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin'])) expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') + expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar') + }) + + it('collapsed sidebar keeps a 60px rail with expand and settings controls', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail')) + const frame = page.locator('[class*="frame"]') + const firstTrack = async (): Promise => (await frame.evaluate( + el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]! + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + expect(await firstTrack()).toBe('60px') + await expect(page.getByRole('button', { name: 'Expand sidebar' }).isVisible()).resolves.toBe(true) + await expect(page.getByRole('button', { name: 'Settings' }).isVisible()).resolves.toBe(true) + await page.getByRole('button', { name: 'Expand sidebar' }).click() + expect(await firstTrack()).toBe('300px') + await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 5c5e989957..c5d279021f 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -128,14 +128,15 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App ref={frameRef} className={css.frame} style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }} - data-sidebar-collapsed={cols.sidebar === 0 || undefined} + data-sidebar-collapsed={panels.sidebar === 0 || undefined} data-details-collapsed={cols.details === 0 || undefined} >
- {/* Render-site slot call with live concession output: the sidebar - stays mounted at zero width (CSS hides it), and sees its rendered - state as owner params decided here, not precomputed upstream. */} - {renderSlot('sidebar', { collapsed: cols.sidebar === 0, width: cols.sidebar })} + {/* Render-site slot call with live concession output: a closed + sidebar keeps the mounted slot at the compact-rail width, and the + component sees its rendered state as owner params decided here + (collapsed follows the preference, not the resolved width). */} + {renderSlot('sidebar', { collapsed: panels.sidebar === 0, width: cols.sidebar })}
( @@ -153,7 +154,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App )} - {cols.sidebar > 0 && } + {/* The collapsed rail is fixed-width: no resize handle while closed. */} + {panels.sidebar > 0 && } {cols.details > 0 && } ) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 5d4d611707..73b23eb6a1 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -4,7 +4,9 @@ * details first, then sidebar, then auto-closing details (derived zero width — * persisted width preferences are never rewritten, so widening the window * restores them). Center absorbs any remaining deficit as the last resort. - * Inputs are the layout store's plain width preferences (0 = closed). + * Inputs are the layout store's plain width preferences (0 = closed); a + * closed sidebar resolves to the fixed SIDEBAR_COLLAPSED control rail while + * closed details resolve to zero width. */ /** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */ @@ -19,6 +21,8 @@ export const SIDEBAR_MIN = 240 export const SIDEBAR_MAX = 420 /** Sidebar width before any user drag. */ export const SIDEBAR_DEFAULT = 300 +/** Closed-sidebar rail: one 28px control between 16px horizontal paddings. */ +export const SIDEBAR_COLLAPSED = 60 /** Details drag clamp floor. */ export const DETAILS_MIN = 300 /** Details drag clamp ceiling. */ @@ -47,10 +51,10 @@ export function clampWidth(px: number, min: number, max: number): number { * @param viewport - available frame width in px. * @param sidebar - sidebar width preference in px (0 = closed). * @param details - details width preference in px (0 = closed). - * @returns resolved widths; details 0 means visually closed (never unmounted). + * @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail. */ export function computeColumns(viewport: number, sidebar: number, details: number): Columns { - const s0 = sidebar === 0 ? 0 : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) + const s0 = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX) // Step 1: everything fits at preferred widths. @@ -60,15 +64,15 @@ export function computeColumns(viewport: number, sidebar: number, details: numbe const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s0 - CENTER_MIN) if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 } - // Step 3: shrink sidebar toward its minimum. - const s1 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) + // Step 3: shrink sidebar toward its minimum (the collapsed rail never shrinks). + const s1 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 } // Step 4: auto-close details (derived — preferences untouched). With the // details pressure gone the sidebar concession is re-solved from preference. if (d1 > 0) { if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 } - const s2 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) + const s2 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 } } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 00555e90dc..1aeb04593b 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -50,9 +50,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Sidebar owner share: live column state from the frame's concession solve. */ export interface SidebarOwnerProps { - /** True when the concession chain rendered the column at zero width. */ + /** True when the sidebar is closed (the column renders the compact control rail). */ collapsed: boolean - /** Rendered column width in px (0 when collapsed). */ + /** Rendered column width in px (SIDEBAR_COLLAPSED when collapsed). */ width: number } diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 7e65445a3a..120197d531 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -15,6 +15,7 @@ import { act, cleanup, render } from '@testing-library/react' import { useSyncExternalStore } from 'react' import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' +import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts' // Session-mode switch for the SessionProvider stub prop. @@ -175,6 +176,16 @@ describe('AppFrame', () => { expect(frame.hasAttribute('data-details-collapsed')).toBe(true) }) + it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => { + const { frame, instance, slotCalls, getByTestId } = mountFrame() + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360]) + expect(getByTestId('sidebar-content')).toBeTruthy() + expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) + const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)! + expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED }) + }) + it('viewport shrink triggers the concession chain via ResizeObserver', () => { const { frame } = mountFrame() frameWidth = 1250 diff --git a/packages/client/ui-layout/tests/columns.spec.ts b/packages/client/ui-layout/tests/columns.spec.ts index 8fb355bb19..6358c45076 100644 --- a/packages/client/ui-layout/tests/columns.spec.ts +++ b/packages/client/ui-layout/tests/columns.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CENTER_MIN, clampWidth, computeColumns, - DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN, + DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN, } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' // Numeric preference form (0 = closed); helpers keep the scenario names readable. @@ -22,8 +22,9 @@ describe('computeColumns', () => { expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 }) }) - it('closed panels contribute zero width', () => { - expect(computeColumns(1920, closed(300), closed(360))).toEqual({ sidebar: 0, center: 1920, details: 0 }) + it('closed sidebar keeps its compact rail while closed details contribute zero width', () => { + expect(computeColumns(1920, closed(300), closed(360))) + .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 }) }) it('preferences beyond the clamp range are clamped before solving', () => { @@ -70,10 +71,14 @@ describe('computeColumns', () => { }) it('sidebar-closed narrow window: details concedes then auto-closes', () => { - const fits = computeColumns(DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT)) - expect(fits).toEqual({ sidebar: 0, center: CENTER_MIN, details: DETAILS_MIN }) - const starved = computeColumns(DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT)) - expect(starved).toEqual({ sidebar: 0, center: DETAILS_MIN + CENTER_MIN - 1, details: 0 }) + const fits = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT)) + expect(fits).toEqual({ sidebar: SIDEBAR_COLLAPSED, center: CENTER_MIN, details: DETAILS_MIN }) + const starved = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT)) + expect(starved).toEqual({ + sidebar: SIDEBAR_COLLAPSED, + center: DETAILS_MIN + CENTER_MIN - 1, + details: 0, + }) }) it('tiny viewport: both panels yield everything to center', () => { @@ -93,9 +98,9 @@ describe('computeColumns', () => { }) describe('computeColumns — degenerate viewports', () => { - it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes all', () => { - // Reaches step 4's re-solve with s0 = 0 (the closed-sidebar arm). + it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => { + // Reaches step 4's re-solve with the compact rail as the sidebar floor. expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT))) - .toEqual({ sidebar: 0, center: 500, details: 0 }) + .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 }) }) }) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 52abd0c673..441cc7477d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 9badca1b82..da418b4d98 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -5,6 +5,8 @@ * standard useSessions hook, viewing state (expansion, search) is local * component state, and rows are derived in render via useMemo (slot design * section 6: derived data is a pure function, no materializing store). + * The collapsed render keeps only the rail controls (expand toggle + + * Settings); the body unmounts, dropping its sessions subscription. */ import { Fragment, useMemo, useState } from 'react' import clsx from 'clsx' @@ -31,12 +33,10 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] } -/** - * Render the sidebar column. - * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). - * @returns the sidebar element tree. - */ -export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { +type SidebarBodyProps = Pick + +/** Expanded-only content; unmounting drops the sessions subscription and viewing state while the rail is collapsed. */ +function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) { const list = useSessions((s) => s) // Wave-2 seam: row highlight expects `current` on the sessions list // snapshot (sessions.current lives with the runtime sessions service). @@ -61,32 +61,7 @@ export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: } return ( -
-
-
- - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS - - -
- - -
- -
+
WorkSpace +
+ ) +} + +/** + * Render the sidebar column. + * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). + * @returns the sidebar element tree. + */ +export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { + return ( +
+
+
+ {!collapsed && ( + + {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} + + deepseek + HARNESS + + )} + +
+ + {!collapsed && ( + + )}
-
+ {!collapsed && } + +
- Settings + {!collapsed && Settings}
) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 6beb07bdc4..dace97a6b3 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -60,17 +60,24 @@ function mount(...summaries: SessionSummary[]) { const sessions = createSnapshotStore(listStateOf(...summaries)) const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) }) const onCreate = vi.fn() - const onToggleSidebar = vi.fn() - const utils = render( + // The owner decides collapsed in production (AppFrame maps the preference); + // the harness mirrors that loop so the toggle drives a re-render. + let collapsed = false + const view = (width: number) => ( , + /> ) + const onToggleSidebar = vi.fn(() => { + collapsed = !collapsed + utils.rerender(view(collapsed ? 60 : 300)) + }) + const utils = render(view(300)) return { sessions, onOpen, onCreate, onToggleSidebar, ...utils } } @@ -151,10 +158,23 @@ describe('SidebarRoot', () => { expect(onCreate).toHaveBeenLastCalledWith('/proj') }) - it('collapse button and group-by menu behave', () => { + it('collapsed rail keeps the expand and settings controls', () => { const { onToggleSidebar } = mount(...projectData()) act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) expect(onToggleSidebar).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Expand sidebar')).toBeTruthy() + expect(screen.getByLabelText('Settings')).toBeTruthy() + expect(screen.queryByText('HARNESS')).toBeNull() + expect(screen.queryByText('New Session')).toBeNull() + expect(screen.queryByRole('tree')).toBeNull() + act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) + expect(onToggleSidebar).toHaveBeenCalledTimes(2) + expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy() + expect(screen.getByText('New Session')).toBeTruthy() + }) + + it('group-by menu behaves', () => { + mount(...projectData()) expect(screen.queryByText('Update')).toBeNull() act(() => { fireEvent.click(screen.getByLabelText('Group by')) }) expect(screen.getByText('Update')).toBeTruthy()