fix(gui): keep sidebar controls when collapsed
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: A collapsed sidebar retains its control rail
|
||||
|
||||
Status: implemented
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Render an expand button over the center column** — rejected because it recovers only the toggle, not the persistent settings area, and splits sidebar chrome across two package owners.
|
||||
- **Keep a zero-width grid track and let the rail overflow it** — rejected because the rail would overlap the center column and leave hit testing and responsive geometry disconnected from the grid.
|
||||
- **Keep the complete sidebar tree mounted and hide it with clipping** — rejected because hidden controls remain in the semantic tree and continue subscribing and rendering even though only two controls belong in the collapsed state.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A collapsed sidebar reserves 60px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior.
|
||||
- The settings entry remains visible but retains its existing placeholder behavior; this change does not introduce an account or settings screen.
|
||||
- Layout solver tests pin the compact width, sidebar component tests pin the visible controls, and the keyless real-bundle web smoke test pins collapse and recovery through the assembled client.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 侧边栏折叠后保留控制栏
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-collapsed-sidebar-control-rail.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
侧边栏关闭操作会持久化 `open: false`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。
|
||||
|
||||
## 决策
|
||||
|
||||
布局将关闭的侧边栏映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。
|
||||
|
||||
`AppFrame` 根据持久化的 `open` 偏好标记侧边栏是否折叠,而不是根据求解后的宽度是否为零来判断。它让侧边栏插槽保持挂载,但在折叠时移除尺寸调整手柄。
|
||||
|
||||
`SidebarRoot` 订阅派生的布尔型打开状态。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在中心列上方渲染展开按钮**:不予采纳,因为这只能恢复开关,无法保留常驻设置区域,同时还会让侧边栏 UI 由两个包(package)分别持有。
|
||||
- **保留宽度为零的网格轨道,让控制栏溢出显示**:不予采纳,因为控制栏会与中心列重叠,还会使命中测试和响应式几何关系脱离网格布局。
|
||||
- **保持完整侧边栏树挂载,并通过裁切将其隐藏**:不予采纳,因为隐藏控件仍留在语义树中,而且会继续订阅和渲染,尽管折叠状态下只需要两个控件。
|
||||
|
||||
## 后果
|
||||
|
||||
- 折叠的侧边栏占用 60px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。
|
||||
- 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。
|
||||
- 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。
|
||||
@@ -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. */
|
||||
@@ -90,7 +91,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
|
||||
@@ -139,6 +140,20 @@ 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 () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-layout
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. Contract: api-contracts v3 §5.
|
||||
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 60px control rail while details closes to zero width. Contract: api-contracts v3 §5.
|
||||
|
||||
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. The `conversation` entry authorizes `conversation.empty` delegation through `children`.
|
||||
|
||||
|
||||
@@ -27,13 +27,8 @@
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Collapsed columns keep children mounted; the border must not paint a 1px seam.
|
||||
Flags live on the frame — DetailsColumn renders inside the provider body and
|
||||
does not know its own width. */
|
||||
.frame[data-sidebar-collapsed] .sidebarCol {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* The details subtree stays mounted at zero width, so its border must not paint
|
||||
a 1px seam. The collapsed sidebar instead retains a bordered compact rail. */
|
||||
.frame[data-details-collapsed] .detailsCol {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
@@ -137,12 +137,14 @@ export function AppFrame(props: AppFrameProps) {
|
||||
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={!sidebar.open || undefined}
|
||||
data-details-collapsed={cols.details === 0 || undefined}
|
||||
>
|
||||
<div className={css.sidebarCol}>{props.sidebar}</div>
|
||||
{props.children}
|
||||
{cols.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
|
||||
{sidebar.open && cols.sidebar > 0
|
||||
? <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />
|
||||
: null}
|
||||
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Pure concession-chain column solver for the three-column AppFrame.
|
||||
* Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking
|
||||
* details first, then sidebar, then auto-closing details (derived zero width —
|
||||
* persisted open/width preferences are never rewritten, so widening the window
|
||||
* restores them). Center absorbs any remaining deficit as the last resort.
|
||||
* details first, then sidebar, then auto-closing details. A closed sidebar
|
||||
* keeps its compact rail; persisted open/width preferences are never rewritten,
|
||||
* so widening the window restores them. Center absorbs any remaining deficit.
|
||||
*/
|
||||
|
||||
/** Panel viewing state consumed by the solver (mirrors LayoutService PanelState). */
|
||||
@@ -21,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,13 +49,11 @@ export function clampWidth(px: number, min: number, max: number): number {
|
||||
* @param viewport - available frame width in px.
|
||||
* @param sidebar - sidebar preference (open flag + persisted width).
|
||||
* @param details - details preference (open flag + persisted width).
|
||||
* @returns resolved widths; details 0 means visually closed (never unmounted).
|
||||
* @returns resolved widths; details 0 means visually closed, while a closed sidebar keeps its compact rail.
|
||||
*/
|
||||
export function computeColumns(viewport: number, sidebar: PanelInput, details: PanelInput): Columns {
|
||||
const want = (p: PanelInput, min: number, max: number): number =>
|
||||
p.open ? clampWidth(p.width, min, max) : 0
|
||||
const s0 = want(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
|
||||
const d0 = want(details, DETAILS_MIN, DETAILS_MAX)
|
||||
const s0 = sidebar.open ? clampWidth(sidebar.width, SIDEBAR_MIN, SIDEBAR_MAX) : SIDEBAR_COLLAPSED
|
||||
const d0 = details.open ? clampWidth(details.width, DETAILS_MIN, DETAILS_MAX) : 0
|
||||
|
||||
// Step 1: everything fits at preferred widths.
|
||||
if (s0 + d0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0 - d0, details: d0 }
|
||||
@@ -63,14 +63,14 @@ export function computeColumns(viewport: number, sidebar: PanelInput, details: P
|
||||
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)
|
||||
const s1 = sidebar.open ? Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) : SIDEBAR_COLLAPSED
|
||||
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.open ? Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) : SIDEBAR_COLLAPSED
|
||||
return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 }
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import { LayoutService } from './service.ts'
|
||||
export { AppFrame, CenterColumn, DetailsColumn, type AppFrameProps } from './AppFrame.tsx'
|
||||
export {
|
||||
clampWidth, computeColumns,
|
||||
CENTER_MIN, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
|
||||
CENTER_MIN, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
|
||||
SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
|
||||
type Columns, type PanelInput,
|
||||
} from './columns.ts'
|
||||
export { LayoutService, type NavState, type PanelState, type ViewId } from './service.ts'
|
||||
|
||||
@@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { AppFrame, CenterColumn, DetailsColumn, type PanelState } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { clampWidth } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { clampWidth, SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
/** Observer stub: captures the callback so tests can fire resizes manually. */
|
||||
let fireResize: (() => void) | null = null
|
||||
@@ -119,6 +119,15 @@ describe('AppFrame', () => {
|
||||
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
|
||||
})
|
||||
|
||||
it('closed sidebar keeps its compact rail and mounted slot content', () => {
|
||||
const { frame, sidebar, getByTestId } = mountFrame()
|
||||
act(() => { sidebar.update((d) => { d.open = false }) })
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
|
||||
expect(getByTestId('sidebar-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
|
||||
const { frame } = mountFrame()
|
||||
frameWidth = 1250
|
||||
|
||||
@@ -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/client'
|
||||
|
||||
const open = (width: number) => ({ open: true, width })
|
||||
@@ -21,8 +21,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 details contributes 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', () => {
|
||||
@@ -69,10 +70,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,8 +98,8 @@ 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).
|
||||
// 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 })
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
# @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: api-contracts v3 §6.
|
||||
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Its collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: api-contracts v3 §6.
|
||||
|
||||
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session hook, actions) and `SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected` (the owner share referenced from ui-layout's slot declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory binds layout/sessions off `RootBinding<ClientContext>`.
|
||||
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session/sidebar-open hooks, actions) and `SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected` (the owner share referenced from ui-layout's slot declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory binds layout/sessions off `RootBinding<ClientContext>`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -15,6 +15,28 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Closed state is a persistent rail: the layout reserves exactly the root's
|
||||
horizontal padding plus one icon control. */
|
||||
.root.collapsed {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.collapsed .headerBlock {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.collapsed .logoRow {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.collapsed .foot {
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
margin-top: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Header block (figma 133:7630): logo row + New Session, gap 16, padBottom 12. */
|
||||
.headerBlock {
|
||||
flex: none;
|
||||
|
||||
@@ -24,12 +24,10 @@ const GROUP_BY_ITEMS = [
|
||||
{ id: 'status', label: 'Status', disabled: true },
|
||||
]
|
||||
|
||||
/**
|
||||
* Render the sidebar column.
|
||||
* @param props - composed slot props (owner share + injected surface, contract/slots.ts).
|
||||
* @returns the sidebar element tree.
|
||||
*/
|
||||
export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootComponentProps) {
|
||||
type SidebarBodyProps = Pick<SidebarRootComponentProps, 'useTree' | 'useCurrent' | 'actions' | 'tree'>
|
||||
|
||||
/** Expanded-only content; unmounting drops tree/current subscriptions while the rail is collapsed. */
|
||||
function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
|
||||
const rows = useTree((s) => s.rows)
|
||||
const query = useTree((s) => s.query)
|
||||
const groupBy = useTree((s) => s.groupBy)
|
||||
@@ -47,32 +45,7 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
|
||||
}
|
||||
|
||||
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={() => { actions.toggleSidebar() }}
|
||||
>
|
||||
<IconPanelLeftOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" className={css.newSession} onClick={() => { actions.create() }}>
|
||||
<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
|
||||
@@ -153,11 +126,66 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
|
||||
))}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sidebar column.
|
||||
* @param props - composed slot props (owner share + injected surface, contract/slots.ts).
|
||||
* @returns the sidebar element tree.
|
||||
*/
|
||||
export function SidebarRoot(props: SidebarRootComponentProps) {
|
||||
const open = props.useSidebarOpen()
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, !open && css.collapsed)}>
|
||||
<div className={css.headerBlock}>
|
||||
<div className={css.logoRow}>
|
||||
{open
|
||||
? (
|
||||
<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>
|
||||
)
|
||||
: null}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
onClick={() => { props.actions.toggleSidebar() }}
|
||||
>
|
||||
<IconPanelLeftOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open
|
||||
? (
|
||||
<button type="button" className={css.newSession} onClick={() => { props.actions.create() }}>
|
||||
<IconNewChatOutline16 size={14} />
|
||||
New Session
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
|
||||
{open
|
||||
? (
|
||||
<SidebarBody
|
||||
useTree={props.useTree}
|
||||
useCurrent={props.useCurrent}
|
||||
actions={props.actions}
|
||||
tree={props.tree}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
||||
<IconSettingsOutline14 />
|
||||
Settings
|
||||
{open ? <span>Settings</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -37,6 +37,8 @@ export type SidebarRootInjected = {
|
||||
useTree: SnapshotSelectorHook<SidebarTreeState>
|
||||
/** Current session selector (row highlight); undefined selects nothing. */
|
||||
useCurrent: () => SessionId | undefined
|
||||
/** Sidebar open selector; the collapsed render keeps only persistent rail controls. */
|
||||
useSidebarOpen: () => boolean
|
||||
actions: SidebarActions
|
||||
tree: SidebarTreeActions
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export function apply(ctx: ClientContext): void {
|
||||
return {
|
||||
useTree: tree.store.useSelector,
|
||||
useCurrent: () => layout.current.useSelector(s => s.sessionId),
|
||||
useSidebarOpen: () => layout.sidebar.useSelector(s => s.open),
|
||||
actions: {
|
||||
open: (id) => { layout.open(id) },
|
||||
create: (cwd) => {
|
||||
|
||||
@@ -31,10 +31,12 @@ async function bench() {
|
||||
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
})
|
||||
const sessions = { list, create: vi.fn(async () => sid('minted')) }
|
||||
const sidebar = createSnapshotStore({ open: true, width: 300 })
|
||||
const layout = {
|
||||
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
|
||||
sidebar,
|
||||
open: vi.fn(),
|
||||
toggleSidebar: vi.fn(),
|
||||
toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }),
|
||||
}
|
||||
ctx.provide('sessions', sessions)
|
||||
ctx.provide('layout', layout)
|
||||
@@ -83,6 +85,10 @@ describe('apply', () => {
|
||||
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Settings')).toBeTruthy()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
|
||||
expect(layout.toggleSidebar).toHaveBeenCalledTimes(2)
|
||||
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
act(() => { fireEvent.click(screen.getByText('alpha')) })
|
||||
|
||||
@@ -51,20 +51,22 @@ function mount(...summaries: SessionSummary[]) {
|
||||
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
|
||||
const tree: SidebarTreeStore = createSidebarTreeStore({ list })
|
||||
const current = createSnapshotStore<{ id: SessionId | undefined }>({ id: undefined })
|
||||
const sidebar = createSnapshotStore({ open: true })
|
||||
const actions: SidebarActions = {
|
||||
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
|
||||
create: vi.fn(),
|
||||
toggleSidebar: vi.fn(),
|
||||
toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }),
|
||||
}
|
||||
const utils = render(
|
||||
<SidebarRoot
|
||||
useTree={tree.store.useSelector}
|
||||
useCurrent={() => current.useSelector((s) => s.id)}
|
||||
useSidebarOpen={() => sidebar.useSelector((s) => s.open)}
|
||||
actions={actions}
|
||||
tree={tree}
|
||||
/>,
|
||||
)
|
||||
return { list, tree, current, actions, ...utils }
|
||||
return { list, tree, current, sidebar, actions, ...utils }
|
||||
}
|
||||
|
||||
const projectData = () => [
|
||||
@@ -139,10 +141,22 @@ describe('SidebarRoot', () => {
|
||||
expect(actions.create).toHaveBeenLastCalledWith('/proj')
|
||||
})
|
||||
|
||||
it('collapse button and group-by menu behave', () => {
|
||||
it('collapsed rail keeps the expand and settings controls', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Settings')).toBeTruthy()
|
||||
expect(screen.queryByText('HARNESS')).toBeNull()
|
||||
expect(screen.queryByText('New Session')).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
|
||||
expect(actions.toggleSidebar).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()
|
||||
|
||||
Reference in New Issue
Block a user