Merge pull request #523 from deepseek-harness/worktree/fix-collapsed-sidebar-rail
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: e959eef37a9e9c0fea79b82ff970daddd9257609
|
||||
2026-07-22-collapsed-sidebar-control-rail.zh.md: 7f6d6529a8aa4a655a1d3292e7f41bfb822f05a3
|
||||
@@ -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 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 (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 56px: a 24px icon column 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 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. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`.
|
||||
|
||||
`SidebarRoot` reads the owner `collapsed` prop and morphs in place rather than swapping renders: the four control rows persist into the rail — expand toggle, new session, new workspace, search, in the same top-down order as their expanded rows — animating their geometry (heights, paddings, margins, capsule borders) on the same curve, each aligned with its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box). Wide-only content (brand, labels, input, session tree) cross-fades out over 200ms, stays mounted while the collapse animates, and unmounts once the 300ms settle passes — dropping the sessions subscription and leaving the rendered and accessibility trees. The search query lives with the root and survives the round trip.
|
||||
|
||||
## 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 56px 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) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
侧边栏关闭操作会持久化宽度偏好 `0`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。
|
||||
|
||||
## 决策
|
||||
|
||||
布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。
|
||||
|
||||
`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out` 配 `--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。
|
||||
|
||||
`SidebarRoot` 读取 owner 的 `collapsed` 属性,原地 morph 而非切换渲染:四个控件行持续存在并演变为控制栏——展开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致——几何(行高、内边距、外边距、胶囊边框)走同一条曲线动画,行为与展开态对应控件对齐(搜索图标会展开侧边栏并聚焦搜索框)。宽态专属内容(品牌标识、文字标签、输入框、会话树)以 200ms 交叉淡出,折叠动画期间保持挂载,300ms settle 后卸载——随之退订会话列表并离开渲染树与可访问性树。搜索关键词由根组件持有,折叠往返后保留。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在中心列上方渲染展开按钮**:不予采纳,因为这只能恢复开关,无法保留常驻设置区域,同时还会让侧边栏 UI 由两个包(package)分别持有。
|
||||
- **保留宽度为零的网格轨道,让控制栏溢出显示**:不予采纳,因为控制栏会与中心列重叠,还会使命中测试和响应式几何关系脱离网格布局。
|
||||
- **保持完整侧边栏树挂载,并通过裁切将其隐藏**:不予采纳,因为隐藏控件仍留在语义树中,而且会继续订阅和渲染,尽管折叠状态下只需要两个控件。
|
||||
|
||||
## 后果
|
||||
|
||||
- 折叠的侧边栏占用 56px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。
|
||||
- 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。
|
||||
- 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 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
|
||||
// chromium. First describe: manifest injection + static serving. Second
|
||||
// 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. */
|
||||
@@ -76,22 +77,13 @@ describe('web boot chain (keyless, real carrier)', () => {
|
||||
expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin')
|
||||
})
|
||||
|
||||
it('boots to the loading page and fail-louds the absent plugin', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
|
||||
await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
|
||||
await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
|
||||
await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
|
||||
// The real UI must not have flipped in: the gate opens only on settled().
|
||||
expect(await page.locator('[class*="frame"]').count()).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the token sheets before any plugin CSS', async () => {
|
||||
const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
|
||||
expect(family.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
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 +133,38 @@ 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 animates to a 56px rail with the four 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]!
|
||||
// The tracks transition on the deepsuite curve; assert the animated
|
||||
// settle rather than an instant jump.
|
||||
const settledTrack = async (px: string): Promise<void> => {
|
||||
await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
|
||||
}
|
||||
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
||||
// Mid-collapse the wide chrome is still mounted, fading — not swapped out.
|
||||
expect(await page.locator('text=HARNESS').count()).toBe(1)
|
||||
await settledTrack('56px')
|
||||
await expect.poll(() => page.locator('text=HARNESS').count(), { timeout: 2000 }).toBe(0)
|
||||
for (const name of ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
|
||||
await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
|
||||
}
|
||||
await page.getByRole('button', { name: 'Expand sidebar' }).click()
|
||||
await settledTrack('300px')
|
||||
await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
|
||||
// Rail search: collapse again, the search control expands and lands in the box.
|
||||
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
||||
await settledTrack('56px')
|
||||
await page.getByRole('button', { name: 'Search sessions' }).click()
|
||||
await settledTrack('300px')
|
||||
const focused = await page.evaluate(() =>
|
||||
(document.activeElement as HTMLInputElement | null)?.placeholder ?? '')
|
||||
expect(focused).toContain('Search')
|
||||
})
|
||||
|
||||
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 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. 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. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.
|
||||
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
/* Collapse/expand animates the tracks on the deepsuite sider curve
|
||||
(--ds-ease-in-out / --ds-transition-duration-slow, ui-theme base.css). */
|
||||
transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* Dragging writes widths at pointer cadence; easing them would detach the
|
||||
column from the handle. */
|
||||
.frame[data-dragging] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.frame {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarCol {
|
||||
@@ -27,13 +42,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;
|
||||
}
|
||||
@@ -51,6 +61,19 @@
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
/* Rides the same curve as the tracks so the pill stays on the moving
|
||||
border during collapse/expand; paused while dragging (frame rule). */
|
||||
transition: left var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.frame[data-dragging] .handle {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.handle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.handle::after {
|
||||
|
||||
@@ -35,13 +35,13 @@ function DetailsColumn(props: { children?: ReactNode }) {
|
||||
}
|
||||
|
||||
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. */
|
||||
function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void }) {
|
||||
function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const origin = useRef(0)
|
||||
const latest = useRef(0)
|
||||
const frame = useRef<number | null>(null)
|
||||
const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag })
|
||||
callbacks.current = { onStart: props.onStart, onDrag: props.onDrag }
|
||||
const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd })
|
||||
callbacks.current = { onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd }
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
@@ -65,6 +65,7 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
|
||||
if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null }
|
||||
callbacks.current.onDrag(latest.current - origin.current)
|
||||
setDragging(false)
|
||||
callbacks.current.onEnd()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@@ -114,8 +115,12 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
|
||||
// it stays frozen for the whole gesture so dx deltas do not compound.
|
||||
const sidebarBase = useRef(0)
|
||||
const detailsBase = useRef(0)
|
||||
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar }, [])
|
||||
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details }, [])
|
||||
// Track-level transitions pause for the whole gesture: eased tracks would
|
||||
// detach the column edge from the pointer (AppFrame.module.css).
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const onDragEnd = useCallback(() => { setDragging(false) }, [])
|
||||
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar; setDragging(true) }, [])
|
||||
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details; setDragging(true) }, [])
|
||||
const onSidebarDrag = useCallback((dx: number) => {
|
||||
actions.setSidebar(sidebarBase.current + dx)
|
||||
}, [actions])
|
||||
@@ -128,14 +133,16 @@ 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}
|
||||
data-dragging={dragging || 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,8 +160,9 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
|
||||
</>
|
||||
)}
|
||||
</SessionProvider>
|
||||
{cols.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
|
||||
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
|
||||
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
|
||||
{panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
||||
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: a 24px icon column between 16px horizontal paddings. */
|
||||
export const SIDEBAR_COLLAPSED = 56
|
||||
/** 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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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. Collapse morphs the four control rows into the layout-owned 56px rail (expand / new session / new workspace / search — search expands and focuses the search box) plus the settings foot: geometry animates on the deepsuite curve while wide-only content cross-fades and unmounts at settle. 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.
|
||||
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
/* Sidebar column (figma 133:7629): vertical stack, gap 8, padding 16/6,
|
||||
sidebar fill + 1px right border painted by the layout column. Header block
|
||||
(logo + New Session) and list area (section header + search + cells) carry
|
||||
their own inner gaps per the style spec (1.2 / 1.3). */
|
||||
/* Sidebar column (figma 133:7629): vertical stack, padding 16/6, sidebar
|
||||
fill + 1px right border painted by the layout column. Collapse morphs in
|
||||
place: the four control rows persist into the 56px rail (one icon each,
|
||||
x-converged by the shrinking column), geometry rides the deepsuite curve
|
||||
while wide-only content cross-fades 200ms; explicit margins own the
|
||||
vertical rhythm in both states so every gap can transition. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
padding: 6px 16px;
|
||||
box-sizing: border-box;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* Header block (figma 133:7630): logo row + New Session, gap 16, padBottom 12. */
|
||||
.headerBlock {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
.root.collapsed {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
/* Logo row: 60px, brand mark left, collapse button right.
|
||||
figma pad is (l,t,r,b)=(4,8,4,8) — horizontal 4, vertical 8. */
|
||||
/* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and
|
||||
unmounts once the collapse settles; remounts fade back in. */
|
||||
.wide {
|
||||
animation: wide-in 200ms var(--ds-ease-in-out);
|
||||
transition: opacity 200ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed .wide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes wide-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored —
|
||||
the toggle is the rail's expand control and slides in with the right edge. */
|
||||
.logoRow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 60px;
|
||||
padding: 8px 4px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed .logoRow {
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Brand group (figma I133:7632): fish + wordmark ride the text ink
|
||||
@@ -79,13 +104,22 @@
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
transition:
|
||||
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* New Session: 38px capsule (figma 133:7634). */
|
||||
.collapsed .iconButton {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* New Session: 38px capsule (figma 133:7634) morphing into the rail's plain
|
||||
icon control — border and fill fade with the label. */
|
||||
.newSession {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -94,6 +128,7 @@
|
||||
gap: 6px;
|
||||
height: 38px;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 20px; /* former headerBlock padBottom 12 + root gap 8 */
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
@@ -103,65 +138,84 @@
|
||||
font-weight: 510;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
background-color 200ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.newSession:hover {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
}
|
||||
|
||||
/* List area (figma 133:7640): section header + search + cells, gap 4.
|
||||
Relative for the bottom fade overlay. */
|
||||
.listArea {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.collapsed .newSession {
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
gap: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
|
||||
transparent -> sidebar fill so it tracks the theme. */
|
||||
.fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 72px;
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
|
||||
pointer-events: none;
|
||||
.collapsed .newSession:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
|
||||
session run, before the next project row. */
|
||||
.batchGap {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
.newSessionLabel {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons. */
|
||||
.collapsed .newSessionLabel {
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons;
|
||||
the right-anchored new-workspace button is the row's rail survivor. */
|
||||
.sectionHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
padding-left: 12px;
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
transition:
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed .sectionHeader {
|
||||
height: 24px;
|
||||
padding-left: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Search input: 38px capsule (figma 133:7649). Upstream binds a dedicated
|
||||
design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped
|
||||
alias — a component token pinned to the static scale mirrors it (ruled
|
||||
compliant: indirect via custom property, upstream-variable equivalent). */
|
||||
/* Search input: 38px capsule (figma 133:7649) morphing into the rail's
|
||||
search control. Upstream binds a dedicated design-system variable (light
|
||||
#F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token
|
||||
pinned to the static scale mirrors it (ruled compliant: indirect via
|
||||
custom property, upstream-variable equivalent). */
|
||||
.search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
|
||||
flex: none;
|
||||
@@ -169,19 +223,64 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
margin-bottom: 8px; /* + 4px area gap = 12px to the first cell (spec padB12) */
|
||||
margin-bottom: 12px; /* former listArea gap 4 + own 8 (spec padB12 to the first cell) */
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
background: var(--dsh-search-input-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
background-color 200ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
|
||||
}
|
||||
|
||||
.collapsed .search {
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
gap: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* The capsule's leading icon, upgraded to the rail's search control. While
|
||||
expanded it is decorative: pointer-events off so clicks reach the label
|
||||
(native input focus); collapsed it becomes the hit target. */
|
||||
.searchButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.collapsed .searchButton {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.collapsed .searchButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -212,6 +311,44 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Tree seat: always mounted so the foot never moves; the tree content inside
|
||||
is wide-only and clips while the column squeezes. */
|
||||
.listArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Relative for the bottom fade overlay. */
|
||||
.treeBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
|
||||
transparent -> sidebar fill so it tracks the theme. */
|
||||
.fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 72px;
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
|
||||
session run, before the next project row. */
|
||||
.batchGap {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Tree list: the only scrolling region. */
|
||||
.list {
|
||||
flex: 1;
|
||||
@@ -229,20 +366,57 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Foot: settings entry (figma 133:7668). */
|
||||
/* Foot: settings entry (figma 133:7668). Left padding lands the 14px glyph
|
||||
on the rail's icon axis when collapsed. */
|
||||
.foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 29px;
|
||||
margin: 10px 0;
|
||||
margin: 18px 0 10px; /* former root gap 8 + own 10 above; root padBottom 6 below */
|
||||
padding: 0 2px 0 6px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
transition:
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.foot:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.collapsed .foot {
|
||||
gap: 0;
|
||||
padding: 0 0 0 5px;
|
||||
}
|
||||
|
||||
.footLabel {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed .footLabel {
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root,
|
||||
.wide,
|
||||
.logoRow,
|
||||
.iconButton,
|
||||
.newSession,
|
||||
.newSessionLabel,
|
||||
.sectionHeader,
|
||||
.search,
|
||||
.foot,
|
||||
.footLabel {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, search,
|
||||
* WorkSpace section header with the group-by menu, session tree list,
|
||||
* Settings foot. Pure presentational — the session list arrives through the
|
||||
* 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).
|
||||
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace
|
||||
* section header with the group-by menu, search, session tree list, Settings
|
||||
* foot. Pure presentational — the session list arrives through the 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).
|
||||
*
|
||||
* Collapse is a morph, not a swap: the four control rows persist into the
|
||||
* 56px rail (collapse/new session/new workspace/search, one icon each, same
|
||||
* top-down order as their expanded rows) and animate their geometry on the
|
||||
* deepsuite curve, while wide-only content (brand, labels, input, tree)
|
||||
* cross-fades out and unmounts once the collapse settles — dropping the
|
||||
* sessions subscription. Rail search expands and focuses the search box.
|
||||
*/
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
FishLogo,
|
||||
@@ -19,6 +26,9 @@ import { deriveRows } from './tree.ts'
|
||||
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
|
||||
import css from './SidebarRoot.module.css'
|
||||
|
||||
/** Wide-content unmount delay; matches --ds-transition-duration-slow (0.3s). */
|
||||
const COLLAPSE_SETTLE_MS = 300
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ id: 'workspace', label: 'WorkSpace' },
|
||||
// Update/Status grouping has no design yet (figma §3) — visible, disabled.
|
||||
@@ -31,24 +41,48 @@ 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) {
|
||||
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
||||
function GroupByMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={GROUP_BY_ITEMS}
|
||||
selectedId="workspace"
|
||||
onSelect={() => { setOpen(false) }}
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.wide)}
|
||||
aria-label="Group by"
|
||||
onClick={() => { setOpen((v) => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
|
||||
/** Live search filter owned by the root (the query outlives the tree). */
|
||||
query: string
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
|
||||
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).
|
||||
const current = useSessions((s) => s.current)
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
const [query, setQuery] = useState('')
|
||||
const rows = useMemo(
|
||||
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
|
||||
[list, expandedProjects, expandedSessions, query],
|
||||
)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const now = Date.now()
|
||||
|
||||
// Presentational lookup (not tree derivation): the group holding the
|
||||
@@ -61,83 +95,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.sectionHeader}>
|
||||
<span className={css.sectionLabel}>WorkSpace</span>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={GROUP_BY_ITEMS}
|
||||
selectedId="workspace"
|
||||
onSelect={() => { setMenuOpen(false) }}
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="Group by"
|
||||
onClick={() => { setMenuOpen((v) => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="New workspace"
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconProjectAddOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className={css.search}>
|
||||
<IconSearchOutline16 size={14} />
|
||||
<input
|
||||
className={css.searchInput}
|
||||
type="text"
|
||||
placeholder="Search name, keywords..."
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value) }}
|
||||
/>
|
||||
{query !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.clearButton}
|
||||
aria-label="Clear search"
|
||||
onClick={() => { setQuery('') }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label="Sessions">
|
||||
{rows.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
@@ -167,11 +125,128 @@ 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) {
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInput = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// Wide content stays mounted while the collapse animates (fading via
|
||||
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
|
||||
const [settled, setSettled] = useState(collapsed)
|
||||
useEffect(() => {
|
||||
if (!collapsed) { setSettled(false); return }
|
||||
const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [collapsed])
|
||||
const wide = !collapsed || !settled
|
||||
|
||||
// Rail search = expand + land in the search box: the flag arms before the
|
||||
// expand toggle; once expanded the input is mounted and takes focus.
|
||||
const [searchOnExpand, setSearchOnExpand] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!collapsed && searchOnExpand) {
|
||||
searchInput.current?.focus()
|
||||
setSearchOnExpand(false)
|
||||
}
|
||||
}, [collapsed, searchOnExpand])
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, collapsed && css.collapsed)}>
|
||||
<div className={css.logoRow}>
|
||||
{wide && (
|
||||
<span className={clsx(css.brand, css.wide)}>
|
||||
{/* 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>
|
||||
|
||||
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
|
||||
<button
|
||||
type="button"
|
||||
className={css.newSession}
|
||||
aria-label="New session"
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconNewChatOutline16 size={14} />
|
||||
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
|
||||
</button>
|
||||
|
||||
<div className={css.sectionHeader}>
|
||||
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
|
||||
{wide && <GroupByMenu />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="New workspace"
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconProjectAddOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded: the row is a click-to-focus field (the leading icon is
|
||||
decorative). Collapsed: the icon is the rail's search control. */}
|
||||
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label="Search sessions"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
|
||||
>
|
||||
<IconSearchOutline16 size={14} />
|
||||
</button>
|
||||
{wide && (
|
||||
<input
|
||||
ref={searchInput}
|
||||
className={clsx(css.searchInput, css.wide)}
|
||||
type="text"
|
||||
placeholder="Search name, keywords..."
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value) }}
|
||||
/>
|
||||
)}
|
||||
{wide && query !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.clearButton, css.wide)}
|
||||
aria-label="Clear search"
|
||||
onClick={() => { setQuery('') }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
|
||||
both states while the tree itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && <SessionTree useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} query={query} />}
|
||||
</div>
|
||||
|
||||
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
||||
<IconSettingsOutline14 />
|
||||
Settings
|
||||
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 ? 56 : 300))
|
||||
})
|
||||
const utils = render(view(300))
|
||||
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
|
||||
}
|
||||
|
||||
@@ -151,10 +158,73 @@ describe('SidebarRoot', () => {
|
||||
expect(onCreate).toHaveBeenLastCalledWith('/proj')
|
||||
})
|
||||
|
||||
it('collapse button and group-by menu behave', () => {
|
||||
const { onToggleSidebar } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledOnce()
|
||||
it('collapse fades the wide content out, then the rail keeps the four controls', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onToggleSidebar, onCreate } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledOnce()
|
||||
// Fade window: the wide chrome is still mounted while it fades.
|
||||
expect(screen.getByText('HARNESS')).toBeTruthy()
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
// Settle: wide content unmounts, the rail controls remain.
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.queryByText('HARNESS')).toBeNull()
|
||||
expect(screen.queryByText('New Session')).toBeNull()
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
// Rail order mirrors the expanded rows: expand, new session, new workspace, search.
|
||||
const rail = ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
|
||||
.map((label) => screen.getByLabelText(label))
|
||||
for (let i = 1; i < rail.length; i++) {
|
||||
expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
}
|
||||
// Rail creation entries route like their expanded counterparts.
|
||||
act(() => { fireEvent.click(screen.getByLabelText('New session')) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rail search expands the sidebar and focuses the search box', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onToggleSidebar } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
expect(document.activeElement).toBe(input)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('the search query survives a collapse/expand round trip', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mount(...projectData())
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
|
||||
const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement
|
||||
expect(restored.value).toBe('forked')
|
||||
expect(screen.getByText('forked child')).toBeTruthy()
|
||||
expect(screen.queryByText('elsewhere')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('group-by menu behaves', () => {
|
||||
mount(...projectData())
|
||||
expect(screen.queryByText('Update')).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
|
||||
expect(screen.getByText('Update')).toBeTruthy()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/* Base font-family variables referenced by the token sheets but defined
|
||||
/* Base variables referenced by the token sheets and component CSS but defined
|
||||
* upstream (deepsuite theme/global.css) — supplied here so the composite
|
||||
* --dsw-font-* variables resolve. Code stack deliberately omits a bare
|
||||
* `monospace` tail (Windows CJK falls back to SimSun otherwise). */
|
||||
* --dsw-font-* variables resolve and motion rides the upstream curve. Code
|
||||
* font stack deliberately omits a bare `monospace` tail (Windows CJK falls
|
||||
* back to SimSun otherwise). */
|
||||
:root {
|
||||
--dsw-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
|
||||
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
|
||||
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ds-transition-duration-slow: 0.3s;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user