fix(gui): keep sidebar controls when collapsed

A closed sidebar previously resolved to a zero-width grid track, clipping
the only toggle and the settings entry with no visible recovery; the closed
preference persisted across reloads, locking the sidebar shut.

- columns.ts maps the closed preference (width 0) to a fixed 60px
  SIDEBAR_COLLAPSED rail through every step of the concession solve;
  closed details still resolve to zero width.
- AppFrame derives data-sidebar-collapsed and the sidebar slot's collapsed
  owner prop from the persisted preference instead of the resolved track
  width, and drops the resize handle while collapsed.
- SidebarRoot reads the owner collapsed prop; the expanded-only body is a
  separate component that unmounts while collapsed (dropping its sessions
  subscription), leaving the expand toggle and Settings in the rail.
- The keyless web smoke gains the ui-sidebar bundle (six real bundles) and
  pins the 60px rail collapse/expand round through the assembled client.
This commit is contained in:
imccyu
2026-07-23 10:51:57 +08:00
parent 59a2fc408b
commit 7e6f0128b5
12 changed files with 153 additions and 80 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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` 控件则留在控制栏中。
## 曾考虑的替代方案

View File

@@ -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<ReturnType<typeof startWebServer>>
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<string> => (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', () => {

View File

@@ -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}
>
<div className={css.sidebarCol}>
{/* 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 })}
</div>
<SessionProvider
empty={() => (
@@ -153,7 +154,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
</>
)}
</SessionProvider>
{cols.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
</div>
)

View File

@@ -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 }
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 })
})
})

View File

@@ -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.

View File

@@ -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<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'>
/** 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 (
<div className={css.root}>
<div className={css.headerBlock}>
<div className={css.logoRow}>
<span className={css.brand}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
<button
type="button"
className={css.iconButton}
aria-label="Collapse sidebar"
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
</div>
<div className={css.listArea}>
<div className={css.listArea}>
<div className={css.sectionHeader}>
<span className={css.sectionLabel}>WorkSpace</span>
<Menu
@@ -167,11 +142,51 @@ export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }:
))}
</div>
<span className={css.fade} />
</div>
)
}
/**
* 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 (
<div className={clsx(css.root, collapsed && css.collapsed)}>
<div className={css.headerBlock}>
<div className={css.logoRow}>
{!collapsed && (
<span className={css.brand}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
)}
<button
type="button"
className={css.iconButton}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
{!collapsed && (
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
)}
</div>
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
{!collapsed && <SidebarBody useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} />}
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
Settings
{!collapsed && <span>Settings</span>}
</div>
</div>
)

View File

@@ -60,17 +60,24 @@ function mount(...summaries: SessionSummary[]) {
const sessions = createSnapshotStore<SessionListState>(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) => (
<SidebarRoot
collapsed={false}
width={300}
collapsed={collapsed}
width={width}
useSessions={hookOf(sessions)}
onOpen={onOpen}
onCreate={onCreate}
onToggleSidebar={onToggleSidebar}
/>,
/>
)
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()