From 93f1eaa9726d40be58c745f3d9aa887f0afd36b9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 22 Jul 2026 20:28:40 +0800 Subject: [PATCH 1/8] fix(gui): keep sidebar controls when collapsed --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 6 ++ ...26-07-22-collapsed-sidebar-control-rail.md | 29 ++++++ ...07-22-collapsed-sidebar-control-rail.zh.md | 29 ++++++ apps/web/tests/smoke-fixture.e2e.ts | 25 ++++- packages/client/ui-layout/README.md | 2 +- .../ui-layout/src/client/AppFrame.module.css | 9 +- .../client/ui-layout/src/client/AppFrame.tsx | 6 +- .../client/ui-layout/src/client/columns.ts | 20 ++-- packages/client/ui-layout/src/client/index.ts | 3 +- .../client/ui-layout/tests/app-frame.spec.tsx | 11 ++- .../client/ui-layout/tests/columns.spec.ts | 23 +++-- packages/client/ui-sidebar/README.md | 4 +- .../src/client/SidebarRoot.module.css | 22 +++++ .../ui-sidebar/src/client/SidebarRoot.tsx | 96 ++++++++++++------- .../ui-sidebar/src/client/contract/slots.ts | 2 + .../client/ui-sidebar/src/client/index.ts | 1 + .../client/ui-sidebar/tests/apply.spec.tsx | 8 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 20 +++- 18 files changed, 240 insertions(+), 76 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml new file mode 100644 index 0000000000..5ae814d291 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md new file mode 100644 index 0000000000..27e7d0e18a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md new file mode 100644 index 0000000000..bfbb5230f9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -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 冒烟测试则通过组装后的客户端固定折叠与恢复行为。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 0c32be5310..d6ec80d7ad 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,9 +1,9 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real // chromium. First describe: manifest injection + fail-loud half. Second -// describe: the settled success pass — five REAL tsdown bundles (the -// infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation +// describe: the settled success pass — six REAL tsdown bundles (the +// infrastructure four + layout/sidebar) load through the DI chain in ?fixture +// mode and the three-column frame appears in one flip. The full conversation // round lands in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -17,13 +17,14 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass (immediately four + layout/sidebar). */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ @@ -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> 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 => (await frame.evaluate( + (el) => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]! + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + expect(await firstTrack()).toBe('60px') + await expect(page.getByRole('button', { name: 'Expand sidebar' }).isVisible()).resolves.toBe(true) + await expect(page.getByRole('button', { name: 'Settings' }).isVisible()).resolves.toBe(true) + await page.getByRole('button', { name: 'Expand sidebar' }).click() + expect(await firstTrack()).toBe('300px') + await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 6c935f4b9d..f0755c3234 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -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`. diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index b7a03c7f2f..8be0ef0107 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -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; } diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index a7efd09311..de771d5120 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -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} >
{props.sidebar}
{props.children} - {cols.sidebar > 0 && } + {sidebar.open && cols.sidebar > 0 + ? + : null} {cols.details > 0 && } ) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index b6950c19e3..4d45d41874 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -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 } } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index e07c5d9896..3f9801c67b 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -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' diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index dfc49be327..be9d056a02 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -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 diff --git a/packages/client/ui-layout/tests/columns.spec.ts b/packages/client/ui-layout/tests/columns.spec.ts index 49d351a6d4..6a4ccc92ce 100644 --- a/packages/client/ui-layout/tests/columns.spec.ts +++ b/packages/client/ui-layout/tests/columns.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CENTER_MIN, clampWidth, computeColumns, - DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN, + DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN, } from '@deepseek-ai/dsh-client-ui-layout/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 }) }) }) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 25f840ab3d..2650b6836b 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -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`. +`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`. ## Model Experience diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 5fdcbce74c..c8f0a08964 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -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; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 01c8fbfcd4..5a4866bc4f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -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 + +/** 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 ( -
-
-
- - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS - - -
- - -
- -
+
WorkSpace +
+ ) +} + +/** + * Render the sidebar column. + * @param props - composed slot props (owner share + injected surface, contract/slots.ts). + * @returns the sidebar element tree. + */ +export function SidebarRoot(props: SidebarRootComponentProps) { + const open = props.useSidebarOpen() + + return ( +
+
+
+ {open + ? ( + + {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} + + deepseek + HARNESS + + ) + : null} + +
+ + {open + ? ( + + ) + : null}
-
+ {open + ? ( + + ) + : null} + +
- Settings + {open ? Settings : null}
) diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 4d1e0dd7f4..4a5d33f7b5 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -37,6 +37,8 @@ export type SidebarRootInjected = { useTree: SnapshotSelectorHook /** 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 } diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index fab3bc4836..91600c3621 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -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) => { diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index ea029c6709..ac4b794466 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -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')) }) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index db03c672dd..bddacca286 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -51,20 +51,22 @@ function mount(...summaries: SessionSummary[]) { const list = createSnapshotStore(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( 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() From 7e6f0128b58db721b2284ca0993f8d483bd7894c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:51:57 +0800 Subject: [PATCH 2/8] 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. --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 4 +- ...26-07-22-collapsed-sidebar-control-rail.md | 8 +- ...07-22-collapsed-sidebar-control-rail.zh.md | 8 +- apps/web/tests/smoke-fixture.e2e.ts | 26 ++++-- .../client/ui-layout/src/client/AppFrame.tsx | 14 ++-- .../client/ui-layout/src/client/columns.ts | 16 ++-- packages/client/ui-layout/src/client/index.ts | 4 +- .../client/ui-layout/tests/app-frame.spec.tsx | 11 +++ .../client/ui-layout/tests/columns.spec.ts | 25 +++--- packages/client/ui-sidebar/README.md | 2 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 83 +++++++++++-------- .../ui-sidebar/tests/sidebar-root.spec.tsx | 32 +++++-- 12 files changed, 153 insertions(+), 80 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml index 5ae814d291..4cb3d0e859 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-collapsed-sidebar-control-rail.md: 27e7d0e18a20b2ea6de8128f9c3518279ec21aca -2026-07-22-collapsed-sidebar-control-rail.zh.md: bfbb5230f952e49960142bb5ce090dc6753487ec +2026-07-22-collapsed-sidebar-control-rail.md: 9f244010a1ec14eeafe707aedfffcf7e5bbe7736 +2026-07-22-collapsed-sidebar-control-rail.zh.md: 53007bf717404b151d0a2d1f673c20f111ee8234 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md index 27e7d0e18a..9f244010a1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -6,15 +6,15 @@ English | [中文](2026-07-22-collapsed-sidebar-control-rail.zh.md) ## Problem -The sidebar close action persisted `open: false`, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout. +The sidebar close action persisted a zero width preference, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout. ## Decision -The layout maps a closed sidebar to the fixed `SIDEBAR_COLLAPSED` width of 60px: one 28px icon control between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. +The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 60px: one 28px icon control between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. -`AppFrame` marks the sidebar collapsed from the persisted `open` preference rather than from a zero resolved width. It keeps the sidebar slot mounted but removes the resize handle while collapsed. +`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. -`SidebarRoot` subscribes to the derived open boolean. Its collapsed render removes the brand, creation controls, search, and session tree from the rendered and accessibility trees; the top control changes to `Expand sidebar`, and the bottom `Settings` control remains in the rail. +`SidebarRoot` reads the owner `collapsed` prop. Its collapsed render removes the brand, creation controls, search, and session tree from the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription; the top control changes to `Expand sidebar`, and the bottom `Settings` control remains in the rail. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md index bfbb5230f9..53007bf717 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -侧边栏关闭操作会持久化 `open: false`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。 +侧边栏关闭操作会持久化宽度偏好 `0`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。 ## 决策 -布局将关闭的侧边栏映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 +布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 -`AppFrame` 根据持久化的 `open` 偏好标记侧边栏是否折叠,而不是根据求解后的宽度是否为零来判断。它让侧边栏插槽保持挂载,但在折叠时移除尺寸调整手柄。 +`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。 -`SidebarRoot` 订阅派生的布尔型打开状态。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。 +`SidebarRoot` 读取 owner 的 `collapsed` 属性。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除——主体组件卸载,随之退订会话列表;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index b75716c615..81b41c4d8c 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,9 +1,9 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real // chromium. First describe: manifest injection + fail-loud half. Second -// describe: the settled success pass — five REAL tsdown bundles (the -// infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation +// describe: the settled success pass — six REAL tsdown bundles (the +// infrastructure four + layout/sidebar) load through the DI chain in ?fixture +// mode and the three-column frame appears in one flip. The full conversation // round lands in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -17,13 +17,14 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass (immediately four + layout/sidebar). */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ @@ -91,7 +92,7 @@ describe('web boot chain (keyless, real carrier)', () => { }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, six real bundles, ?fixture)', () => { const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -141,6 +142,21 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( const owners = await page.evaluate(() => [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin'])) expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') + expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar') + }) + + it('collapsed sidebar keeps a 60px rail with expand and settings controls', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail')) + const frame = page.locator('[class*="frame"]') + const firstTrack = async (): Promise => (await frame.evaluate( + el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]! + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + expect(await firstTrack()).toBe('60px') + await expect(page.getByRole('button', { name: 'Expand sidebar' }).isVisible()).resolves.toBe(true) + await expect(page.getByRole('button', { name: 'Settings' }).isVisible()).resolves.toBe(true) + await page.getByRole('button', { name: 'Expand sidebar' }).click() + expect(await firstTrack()).toBe('300px') + await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 5c5e989957..c5d279021f 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -128,14 +128,15 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App ref={frameRef} className={css.frame} style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }} - data-sidebar-collapsed={cols.sidebar === 0 || undefined} + data-sidebar-collapsed={panels.sidebar === 0 || undefined} data-details-collapsed={cols.details === 0 || undefined} >
- {/* Render-site slot call with live concession output: the sidebar - stays mounted at zero width (CSS hides it), and sees its rendered - state as owner params decided here, not precomputed upstream. */} - {renderSlot('sidebar', { collapsed: cols.sidebar === 0, width: cols.sidebar })} + {/* Render-site slot call with live concession output: a closed + sidebar keeps the mounted slot at the compact-rail width, and the + component sees its rendered state as owner params decided here + (collapsed follows the preference, not the resolved width). */} + {renderSlot('sidebar', { collapsed: panels.sidebar === 0, width: cols.sidebar })}
( @@ -153,7 +154,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App )} - {cols.sidebar > 0 && } + {/* The collapsed rail is fixed-width: no resize handle while closed. */} + {panels.sidebar > 0 && } {cols.details > 0 && }
) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 5d4d611707..73b23eb6a1 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -4,7 +4,9 @@ * details first, then sidebar, then auto-closing details (derived zero width — * persisted width preferences are never rewritten, so widening the window * restores them). Center absorbs any remaining deficit as the last resort. - * Inputs are the layout store's plain width preferences (0 = closed). + * Inputs are the layout store's plain width preferences (0 = closed); a + * closed sidebar resolves to the fixed SIDEBAR_COLLAPSED control rail while + * closed details resolve to zero width. */ /** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */ @@ -19,6 +21,8 @@ export const SIDEBAR_MIN = 240 export const SIDEBAR_MAX = 420 /** Sidebar width before any user drag. */ export const SIDEBAR_DEFAULT = 300 +/** Closed-sidebar rail: one 28px control between 16px horizontal paddings. */ +export const SIDEBAR_COLLAPSED = 60 /** Details drag clamp floor. */ export const DETAILS_MIN = 300 /** Details drag clamp ceiling. */ @@ -47,10 +51,10 @@ export function clampWidth(px: number, min: number, max: number): number { * @param viewport - available frame width in px. * @param sidebar - sidebar width preference in px (0 = closed). * @param details - details width preference in px (0 = closed). - * @returns resolved widths; details 0 means visually closed (never unmounted). + * @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail. */ export function computeColumns(viewport: number, sidebar: number, details: number): Columns { - const s0 = sidebar === 0 ? 0 : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) + const s0 = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX) // Step 1: everything fits at preferred widths. @@ -60,15 +64,15 @@ export function computeColumns(viewport: number, sidebar: number, details: numbe const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s0 - CENTER_MIN) if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 } - // Step 3: shrink sidebar toward its minimum. - const s1 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) + // Step 3: shrink sidebar toward its minimum (the collapsed rail never shrinks). + const s1 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 } // Step 4: auto-close details (derived — preferences untouched). With the // details pressure gone the sidebar concession is re-solved from preference. if (d1 > 0) { if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 } - const s2 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) + const s2 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 } } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 00555e90dc..1aeb04593b 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -50,9 +50,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Sidebar owner share: live column state from the frame's concession solve. */ export interface SidebarOwnerProps { - /** True when the concession chain rendered the column at zero width. */ + /** True when the sidebar is closed (the column renders the compact control rail). */ collapsed: boolean - /** Rendered column width in px (0 when collapsed). */ + /** Rendered column width in px (SIDEBAR_COLLAPSED when collapsed). */ width: number } diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 7e65445a3a..120197d531 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -15,6 +15,7 @@ import { act, cleanup, render } from '@testing-library/react' import { useSyncExternalStore } from 'react' import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' +import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts' // Session-mode switch for the SessionProvider stub prop. @@ -175,6 +176,16 @@ describe('AppFrame', () => { expect(frame.hasAttribute('data-details-collapsed')).toBe(true) }) + it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => { + const { frame, instance, slotCalls, getByTestId } = mountFrame() + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360]) + expect(getByTestId('sidebar-content')).toBeTruthy() + expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) + const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)! + expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED }) + }) + it('viewport shrink triggers the concession chain via ResizeObserver', () => { const { frame } = mountFrame() frameWidth = 1250 diff --git a/packages/client/ui-layout/tests/columns.spec.ts b/packages/client/ui-layout/tests/columns.spec.ts index 8fb355bb19..6358c45076 100644 --- a/packages/client/ui-layout/tests/columns.spec.ts +++ b/packages/client/ui-layout/tests/columns.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CENTER_MIN, clampWidth, computeColumns, - DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN, + DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN, } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' // Numeric preference form (0 = closed); helpers keep the scenario names readable. @@ -22,8 +22,9 @@ describe('computeColumns', () => { expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 }) }) - it('closed panels contribute zero width', () => { - expect(computeColumns(1920, closed(300), closed(360))).toEqual({ sidebar: 0, center: 1920, details: 0 }) + it('closed sidebar keeps its compact rail while closed details contribute zero width', () => { + expect(computeColumns(1920, closed(300), closed(360))) + .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 }) }) it('preferences beyond the clamp range are clamped before solving', () => { @@ -70,10 +71,14 @@ describe('computeColumns', () => { }) it('sidebar-closed narrow window: details concedes then auto-closes', () => { - const fits = computeColumns(DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT)) - expect(fits).toEqual({ sidebar: 0, center: CENTER_MIN, details: DETAILS_MIN }) - const starved = computeColumns(DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT)) - expect(starved).toEqual({ sidebar: 0, center: DETAILS_MIN + CENTER_MIN - 1, details: 0 }) + const fits = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT)) + expect(fits).toEqual({ sidebar: SIDEBAR_COLLAPSED, center: CENTER_MIN, details: DETAILS_MIN }) + const starved = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT)) + expect(starved).toEqual({ + sidebar: SIDEBAR_COLLAPSED, + center: DETAILS_MIN + CENTER_MIN - 1, + details: 0, + }) }) it('tiny viewport: both panels yield everything to center', () => { @@ -93,9 +98,9 @@ describe('computeColumns', () => { }) describe('computeColumns — degenerate viewports', () => { - it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes all', () => { - // Reaches step 4's re-solve with s0 = 0 (the closed-sidebar arm). + it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => { + // Reaches step 4's re-solve with the compact rail as the sidebar floor. expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT))) - .toEqual({ sidebar: 0, center: 500, details: 0 }) + .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 }) }) }) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 52abd0c673..441cc7477d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 9badca1b82..da418b4d98 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -5,6 +5,8 @@ * standard useSessions hook, viewing state (expansion, search) is local * component state, and rows are derived in render via useMemo (slot design * section 6: derived data is a pure function, no materializing store). + * The collapsed render keeps only the rail controls (expand toggle + + * Settings); the body unmounts, dropping its sessions subscription. */ import { Fragment, useMemo, useState } from 'react' import clsx from 'clsx' @@ -31,12 +33,10 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] } -/** - * Render the sidebar column. - * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). - * @returns the sidebar element tree. - */ -export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { +type SidebarBodyProps = Pick + +/** Expanded-only content; unmounting drops the sessions subscription and viewing state while the rail is collapsed. */ +function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) { const list = useSessions((s) => s) // Wave-2 seam: row highlight expects `current` on the sessions list // snapshot (sessions.current lives with the runtime sessions service). @@ -61,32 +61,7 @@ export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: } return ( -
-
-
- - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS - - -
- - -
- -
+
WorkSpace +
+ ) +} + +/** + * Render the sidebar column. + * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). + * @returns the sidebar element tree. + */ +export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { + return ( +
+
+
+ {!collapsed && ( + + {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} + + deepseek + HARNESS + + )} + +
+ + {!collapsed && ( + + )}
-
+ {!collapsed && } + +
- Settings + {!collapsed && Settings}
) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 6beb07bdc4..dace97a6b3 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -60,17 +60,24 @@ function mount(...summaries: SessionSummary[]) { const sessions = createSnapshotStore(listStateOf(...summaries)) const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) }) const onCreate = vi.fn() - const onToggleSidebar = vi.fn() - const utils = render( + // The owner decides collapsed in production (AppFrame maps the preference); + // the harness mirrors that loop so the toggle drives a re-render. + let collapsed = false + const view = (width: number) => ( , + /> ) + const onToggleSidebar = vi.fn(() => { + collapsed = !collapsed + utils.rerender(view(collapsed ? 60 : 300)) + }) + const utils = render(view(300)) return { sessions, onOpen, onCreate, onToggleSidebar, ...utils } } @@ -151,10 +158,23 @@ describe('SidebarRoot', () => { expect(onCreate).toHaveBeenLastCalledWith('/proj') }) - it('collapse button and group-by menu behave', () => { + it('collapsed rail keeps the expand and settings controls', () => { const { onToggleSidebar } = mount(...projectData()) act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) expect(onToggleSidebar).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Expand sidebar')).toBeTruthy() + expect(screen.getByLabelText('Settings')).toBeTruthy() + expect(screen.queryByText('HARNESS')).toBeNull() + expect(screen.queryByText('New Session')).toBeNull() + expect(screen.queryByRole('tree')).toBeNull() + act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) + expect(onToggleSidebar).toHaveBeenCalledTimes(2) + expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy() + expect(screen.getByText('New Session')).toBeTruthy() + }) + + it('group-by menu behaves', () => { + mount(...projectData()) expect(screen.queryByText('Update')).toBeNull() act(() => { fireEvent.click(screen.getByLabelText('Group by')) }) expect(screen.getByText('Update')).toBeTruthy() From a6649fb54523f62eadcf87c03994000bad24c515 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:52:46 +0800 Subject: [PATCH 3/8] test(web): drop the stale fail-loud boot smoke case Since the store engine moved into the client runtime, the ui-layout bundle externalizes @deepseek-ai/dsh-client-runtime/client. This case's two-row manifest (ui-layout + a deliberately absent probe) no longer boots ui-layout at all: the unseeded runtime specifier fails it first, serial loading stops, and the probe never reaches the failure list, so the assertion times out on a premise the engine migration retired. --- apps/web/tests/smoke-fixture.e2e.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 81b41c4d8c..136a12599d 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,6 +1,6 @@ // 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 +// 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 @@ -77,15 +77,6 @@ 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) From 5da8e3b7872ca34114e71289877eb8a238a973ca Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:45:03 +0800 Subject: [PATCH 4/8] feat(gui): animate sidebar collapse and grow the rail control set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapsed rail becomes a 56px icon column (24px controls between 16px paddings) carrying expand, new session, search, and new workspace — each aligned with its expanded counterpart; rail search expands the sidebar and focuses the search box. Collapse/expand now animates: the frame transitions grid-template-columns (and the surviving handle its left) on the deepsuite sider curve — --ds-ease-in-out over --ds-transition-duration-slow, supplied by ui-theme's base sheet. Transitions pause during drags (data-dragging on the frame, set for the whole gesture) and under prefers-reduced-motion. --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 4 +- ...26-07-22-collapsed-sidebar-control-rail.md | 8 +- ...07-22-collapsed-sidebar-control-rail.zh.md | 8 +- apps/web/tests/smoke-fixture.e2e.ts | 24 ++++- packages/client/ui-layout/README.md | 2 +- .../ui-layout/src/client/AppFrame.module.css | 28 +++++ .../client/ui-layout/src/client/AppFrame.tsx | 20 ++-- .../client/ui-layout/src/client/columns.ts | 4 +- packages/client/ui-sidebar/README.md | 2 +- .../src/client/SidebarRoot.module.css | 22 ++-- .../ui-sidebar/src/client/SidebarRoot.tsx | 100 ++++++++++++++---- .../ui-sidebar/tests/sidebar-root.spec.tsx | 21 +++- packages/client/ui-theme/src/styles/base.css | 9 +- 13 files changed, 186 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml index 4cb3d0e859..5a06df6e59 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-collapsed-sidebar-control-rail.md: 9f244010a1ec14eeafe707aedfffcf7e5bbe7736 -2026-07-22-collapsed-sidebar-control-rail.zh.md: 53007bf717404b151d0a2d1f673c20f111ee8234 +2026-07-22-collapsed-sidebar-control-rail.md: 90039110f4c1e97002fe45ebe42697452b2c6155 +2026-07-22-collapsed-sidebar-control-rail.zh.md: e5cd1c4911bbe02e10d2f1506943024bde862f12 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md index 9f244010a1..90039110f4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -10,11 +10,11 @@ The sidebar close action persisted a zero width preference, and the layout mappe ## Decision -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. +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. +`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. 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. +`SidebarRoot` reads the owner `collapsed` prop. Its collapsed render is the rail: expand toggle, new session, search, and new workspace icons (each aligned with its expanded counterpart's behavior — the search icon expands the sidebar and focuses the search box) plus the `Settings` foot. The brand, capsule button, search field, and session tree leave the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription. ## Alternatives considered @@ -24,6 +24,6 @@ The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COL ## Consequences -- A collapsed sidebar reserves 60px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior. +- 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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md index 53007bf717..e5cd1c4911 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -10,11 +10,11 @@ Status: implemented ## 决策 -布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 +布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 -`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。 +`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` 属性。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除——主体组件卸载,随之退订会话列表;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。 +`SidebarRoot` 读取 owner 的 `collapsed` 属性。折叠渲染即控制栏:展开开关、新建会话、搜索、新建工作区四个图标(行为与展开态对应控件对齐——搜索图标会展开侧边栏并聚焦搜索框),加上底部的 `Settings`。品牌标识、胶囊按钮、搜索框和会话树离开渲染树与可访问性树——主体组件卸载,随之退订会话列表。 ## 曾考虑的替代方案 @@ -24,6 +24,6 @@ Status: implemented ## 后果 -- 折叠的侧边栏占用 60px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。 +- 折叠的侧边栏占用 56px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。 - 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。 - 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 136a12599d..1d4fd671bb 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -136,18 +136,32 @@ describe('web boot chain success pass (keyless, six real bundles, ?fixture)', () expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar') }) - it('collapsed sidebar keeps a 60px rail with expand and settings controls', async () => { + 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 => (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 => { + await expect.poll(firstTrack, { timeout: 2000 }).toBe(px) + } 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 settledTrack('56px') + for (const name of ['Expand sidebar', 'New session', 'Search sessions', 'New workspace', 'Settings']) { + await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true) + } await page.getByRole('button', { name: 'Expand sidebar' }).click() - expect(await firstTrack()).toBe('300px') + 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', () => { diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index c334e18903..6cb5fa29a4 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -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. A closed sidebar retains a 60px control rail while details closes to zero width. 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` 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'. diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index 8be0ef0107..631bb929db 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -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 { @@ -46,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 { diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index c5d279021f..e40c94454d 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -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(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) => { 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]) @@ -130,6 +135,7 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }} data-sidebar-collapsed={panels.sidebar === 0 || undefined} data-details-collapsed={cols.details === 0 || undefined} + data-dragging={dragging || undefined} >
{/* Render-site slot call with live concession output: a closed @@ -155,8 +161,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App )} {/* The collapsed rail is fixed-width: no resize handle while closed. */} - {panels.sidebar > 0 && } - {cols.details > 0 && } + {panels.sidebar > 0 && } + {cols.details > 0 && }
) } diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 73b23eb6a1..d7a63aafa2 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -21,8 +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 +/** 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. */ diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 441cc7477d..b398db9d3a 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. 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). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render is the layout-owned compact rail: expand / new session / search (expands and focuses the search box) / new workspace icons plus the settings entry. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index c8f0a08964..571450d13f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -15,24 +15,24 @@ font-size: 14px; } -/* Closed state is a persistent rail: the layout reserves exactly the root's - horizontal padding plus one icon control. */ +/* Closed state is a persistent rail: a 24px icon column between the 16px + horizontal paddings (SIDEBAR_COLLAPSED = 56). Controls mirror their + expanded counterparts top-down: expand, new session, search, new + workspace; settings keeps the foot. */ .root.collapsed { - gap: 0; + align-items: center; + gap: 8px; + padding: 14px 16px 6px; } -.collapsed .headerBlock { - padding-bottom: 0; -} - -.collapsed .logoRow { - justify-content: center; - padding-inline: 0; +.collapsed .iconButton { + width: 24px; + height: 24px; } .collapsed .foot { justify-content: center; - width: 28px; + width: 24px; margin-top: auto; padding: 0; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index da418b4d98..60c94d53a5 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -5,10 +5,11 @@ * 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. + * The collapsed render is the compact rail: expand / new session / search / + * new workspace icons plus the Settings foot; the body unmounts, dropping + * its sessions subscription. Rail search expands and focuses the search box. */ -import { Fragment, useMemo, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import clsx from 'clsx' import { FishLogo, @@ -33,10 +34,13 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] } -type SidebarBodyProps = Pick +type SidebarBodyProps = Pick & { + /** Focus the search input on mount (rail search control expands into search). */ + autoFocusSearch: boolean +} /** Expanded-only content; unmounting drops the sessions subscription and viewing state while the rail is collapsed. */ -function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) { +function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: 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). @@ -99,6 +103,7 @@ function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) { type="text" placeholder="Search name, keywords..." value={query} + autoFocus={autoFocusSearch} onChange={(e) => { setQuery(e.target.value) }} /> {query !== '' && ( @@ -152,41 +157,90 @@ function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) { * @returns the sidebar element tree. */ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { + // Rail search = expand + land in the search box: the flag arms right before + // the expand toggle, the remounting SidebarBody autofocuses its input, and + // the post-commit effect disarms so later remounts stay unfocused. + const [searchOnExpand, setSearchOnExpand] = useState(false) + useEffect(() => { + if (!collapsed && searchOnExpand) setSearchOnExpand(false) + }, [collapsed, searchOnExpand]) + + if (collapsed) { + // Rail (figma parity with deepsuite CollapsedSider): the four controls + // mirror their expanded counterparts top-down; actions that need the + // expanded surface expand first. + return ( +
+ + + + +
+ +
+
+ ) + } + return ( -
+
- {!collapsed && ( - - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS - - )} + + {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} + + deepseek + HARNESS +
- {!collapsed && ( - - )} +
- {!collapsed && } +
- {!collapsed && Settings} + Settings
) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index dace97a6b3..e9367a8388 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -75,7 +75,7 @@ function mount(...summaries: SessionSummary[]) { ) const onToggleSidebar = vi.fn(() => { collapsed = !collapsed - utils.rerender(view(collapsed ? 60 : 300)) + utils.rerender(view(collapsed ? 56 : 300)) }) const utils = render(view(300)) return { sessions, onOpen, onCreate, onToggleSidebar, ...utils } @@ -158,21 +158,36 @@ describe('SidebarRoot', () => { expect(onCreate).toHaveBeenLastCalledWith('/proj') }) - it('collapsed rail keeps the expand and settings controls', () => { - const { onToggleSidebar } = mount(...projectData()) + it('collapsed rail keeps the four controls and settings', () => { + const { onToggleSidebar, onCreate } = mount(...projectData()) act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) expect(onToggleSidebar).toHaveBeenCalledOnce() expect(screen.getByLabelText('Expand sidebar')).toBeTruthy() + expect(screen.getByLabelText('New session')).toBeTruthy() + expect(screen.getByLabelText('Search sessions')).toBeTruthy() + expect(screen.getByLabelText('New workspace')).toBeTruthy() expect(screen.getByLabelText('Settings')).toBeTruthy() expect(screen.queryByText('HARNESS')).toBeNull() expect(screen.queryByText('New Session')).toBeNull() expect(screen.queryByRole('tree')).toBeNull() + // 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() }) + it('rail search expands the sidebar and focuses the search box', () => { + const { onToggleSidebar } = mount(...projectData()) + act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) + act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) + expect(onToggleSidebar).toHaveBeenCalledTimes(2) + const input = screen.getByPlaceholderText('Search name, keywords...') + expect(document.activeElement).toBe(input) + }) + it('group-by menu behaves', () => { mount(...projectData()) expect(screen.queryByText('Update')).toBeNull() diff --git a/packages/client/ui-theme/src/styles/base.css b/packages/client/ui-theme/src/styles/base.css index 7fa58107d2..2d1acde71d 100644 --- a/packages/client/ui-theme/src/styles/base.css +++ b/packages/client/ui-theme/src/styles/base.css @@ -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; } From 698f46b0c99775e21af6ecceb624c11ce3924674 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:03:32 +0800 Subject: [PATCH 5/8] feat(gui): morph the sidebar collapse instead of swapping renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapse read as a hard cut: only the track width animated while the panel content swapped instantly and the rail icons bore no relation to the expanded layout. Now the four control rows persist across the transition — collapse toggle, new session, new workspace, search, the same top-down order as their expanded rows — and morph their geometry (row heights, paddings, margins, capsule borders) on the deepsuite curve, so each rail icon is its expanded control converging onto the 56px axis. Wide-only content (brand, labels, input, session tree) cross-fades over 200ms, stays mounted while the collapse animates, and unmounts at the 300ms settle, still dropping the sessions subscription. The search query moves up to the root and survives the round trip; rail search focuses the surviving input after expand instead of remount-autofocus. --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 4 +- ...26-07-22-collapsed-sidebar-control-rail.md | 2 +- ...07-22-collapsed-sidebar-control-rail.zh.md | 2 +- apps/web/tests/smoke-fixture.e2e.ts | 5 +- packages/client/ui-sidebar/README.md | 2 +- .../src/client/SidebarRoot.module.css | 276 ++++++++++++++---- .../ui-sidebar/src/client/SidebarRoot.tsx | 254 ++++++++-------- .../ui-sidebar/tests/sidebar-root.spec.tsx | 85 ++++-- 8 files changed, 413 insertions(+), 217 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml index 5a06df6e59..9d8ccb1790 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-collapsed-sidebar-control-rail.md: 90039110f4c1e97002fe45ebe42697452b2c6155 -2026-07-22-collapsed-sidebar-control-rail.zh.md: e5cd1c4911bbe02e10d2f1506943024bde862f12 +2026-07-22-collapsed-sidebar-control-rail.md: e959eef37a9e9c0fea79b82ff970daddd9257609 +2026-07-22-collapsed-sidebar-control-rail.zh.md: 7f6d6529a8aa4a655a1d3292e7f41bfb822f05a3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md index 90039110f4..e959eef37a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -14,7 +14,7 @@ The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COL `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. Its collapsed render is the rail: expand toggle, new session, search, and new workspace icons (each aligned with its expanded counterpart's behavior — the search icon expands the sidebar and focuses the search box) plus the `Settings` foot. The brand, capsule button, search field, and session tree leave the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription. +`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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md index e5cd1c4911..7f6d6529a8 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -14,7 +14,7 @@ Status: implemented `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` 属性。折叠渲染即控制栏:展开开关、新建会话、搜索、新建工作区四个图标(行为与展开态对应控件对齐——搜索图标会展开侧边栏并聚焦搜索框),加上底部的 `Settings`。品牌标识、胶囊按钮、搜索框和会话树离开渲染树与可访问性树——主体组件卸载,随之退订会话列表。 +`SidebarRoot` 读取 owner 的 `collapsed` 属性,原地 morph 而非切换渲染:四个控件行持续存在并演变为控制栏——展开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致——几何(行高、内边距、外边距、胶囊边框)走同一条曲线动画,行为与展开态对应控件对齐(搜索图标会展开侧边栏并聚焦搜索框)。宽态专属内容(品牌标识、文字标签、输入框、会话树)以 200ms 交叉淡出,折叠动画期间保持挂载,300ms settle 后卸载——随之退订会话列表并离开渲染树与可访问性树。搜索关键词由根组件持有,折叠往返后保留。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 1d4fd671bb..3f1c4449fd 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -147,8 +147,11 @@ describe('web boot chain success pass (keyless, six real bundles, ?fixture)', () 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') - for (const name of ['Expand sidebar', 'New session', 'Search sessions', 'New workspace', 'Settings']) { + 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() diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index b398db9d3a..33cdeb756d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render is the layout-owned compact rail: expand / new session / search (expands and focuses the search box) / new workspace icons plus the settings entry. 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. diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 571450d13f..c580d47b75 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -1,61 +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); } -/* Closed state is a persistent rail: a 24px icon column between the 16px - horizontal paddings (SIDEBAR_COLLAPSED = 56). Controls mirror their - expanded counterparts top-down: expand, new session, search, new - workspace; settings keeps the foot. */ .root.collapsed { - align-items: center; - gap: 8px; - padding: 14px 16px 6px; + padding-top: 14px; } -.collapsed .iconButton { - width: 24px; - height: 24px; +/* 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 .foot { - justify-content: center; - width: 24px; - margin-top: auto; - padding: 0; +.collapsed .wide { + opacity: 0; } -/* 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; +@keyframes wide-in { + from { opacity: 0; } } -/* Logo row: 60px, brand mark left, collapse button right. - figma pad is (l,t,r,b)=(4,8,4,8) — horizontal 4, vertical 8. */ +/* 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 @@ -101,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; @@ -116,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; @@ -125,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; @@ -191,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; @@ -234,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; @@ -251,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; + } +} diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 60c94d53a5..a2f730b2d4 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -1,15 +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). - * The collapsed render is the compact rail: expand / new session / search / - * new workspace icons plus the Settings foot; the body unmounts, dropping - * its sessions subscription. Rail search expands and focuses the search box. + * 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, useEffect, useMemo, useState } from 'react' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { FishLogo, @@ -22,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. @@ -34,25 +41,48 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] } -type SidebarBodyProps = Pick & { - /** Focus the search input on mount (rail search control expands into search). */ - autoFocusSearch: boolean +/** Group-by strategy menu; own open state so it resets with the wide chrome. */ +function GroupByMenu() { + const [open, setOpen] = useState(false) + return ( + { setOpen(false) }} + items={GROUP_BY_ITEMS} + selectedId="workspace" + onSelect={() => { setOpen(false) }} + align="end" + anchor={( + + )} + /> + ) } -/** Expanded-only content; unmounting drops the sessions subscription and viewing state while the rail is collapsed. */ -function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: SidebarBodyProps) { +type SessionTreeProps = Pick & { + /** 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([]) const [expandedSessions, setExpandedSessions] = useState([]) - 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 @@ -65,59 +95,7 @@ function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: Sidebar } return ( -
-
- WorkSpace - { setMenuOpen(false) }} - items={GROUP_BY_ITEMS} - selectedId="workspace" - onSelect={() => { setMenuOpen(false) }} - align="end" - anchor={( - - )} - /> - -
- - - +
{rows.length === 0 && (
{query === '' ? 'No sessions yet' : 'No matches'}
@@ -157,44 +135,65 @@ function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: Sidebar * @returns the sidebar element tree. */ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { - // Rail search = expand + land in the search box: the flag arms right before - // the expand toggle, the remounting SidebarBody autofocuses its input, and - // the post-commit effect disarms so later remounts stay unfocused. + // 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(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) setSearchOnExpand(false) + if (!collapsed && searchOnExpand) { + searchInput.current?.focus() + setSearchOnExpand(false) + } }, [collapsed, searchOnExpand]) - if (collapsed) { - // Rail (figma parity with deepsuite CollapsedSider): the four controls - // mirror their expanded counterparts top-down; actions that need the - // expanded surface expand first. - return ( -
+ return ( +
+
+ {wide && ( + + {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} + + deepseek + HARNESS + + )} - - +
+ + + +
+ {wide && WorkSpace} + {wide && } -
- -
- ) - } - return ( -
-
-
- - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS - + {/* Expanded: the row is a click-to-focus field (the leading icon is + decorative). Collapsed: the icon is the rail's search control. */} +
{ if (!collapsed) searchInput.current?.focus() }}> + + {wide && ( + { setQuery(e.target.value) }} + /> + )} + {wide && query !== '' && ( -
- - + )}
- + {/* Always-mounted seat: its flex slot pins the foot to the bottom in + both states while the tree itself is wide-only. */} +
+ {wide && } +
- Settings + {wide && Settings}
) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index e9367a8388..b0e8a9f769 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -158,34 +158,69 @@ describe('SidebarRoot', () => { expect(onCreate).toHaveBeenLastCalledWith('/proj') }) - it('collapsed rail keeps the four controls and settings', () => { - const { onToggleSidebar, onCreate } = mount(...projectData()) - act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) - expect(onToggleSidebar).toHaveBeenCalledOnce() - expect(screen.getByLabelText('Expand sidebar')).toBeTruthy() - expect(screen.getByLabelText('New session')).toBeTruthy() - expect(screen.getByLabelText('Search sessions')).toBeTruthy() - expect(screen.getByLabelText('New workspace')).toBeTruthy() - expect(screen.getByLabelText('Settings')).toBeTruthy() - expect(screen.queryByText('HARNESS')).toBeNull() - expect(screen.queryByText('New Session')).toBeNull() - expect(screen.queryByRole('tree')).toBeNull() - // 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() + 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', () => { - const { onToggleSidebar } = mount(...projectData()) - act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) - act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) - expect(onToggleSidebar).toHaveBeenCalledTimes(2) - const input = screen.getByPlaceholderText('Search name, keywords...') - expect(document.activeElement).toBe(input) + 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', () => { From 00d5b69882961c8136334cc04e5c512825e68c0a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:09:52 +0800 Subject: [PATCH 6/8] chore: run primary CI before push --- .../process/2026-06-11-quality-gates.md | 2 +- .../2026-07-06-parallel-pre-push-gates.md | 2 +- .../2026-07-22-fast-local-git-hooks.i18n.yaml | 4 +- .../2026-07-22-fast-local-git-hooks.md | 2 + .../2026-07-22-fast-local-git-hooks.zh.md | 2 + ...-23-local-primary-ci-before-push.i18n.yaml | 6 +++ ...2026-07-23-local-primary-ci-before-push.md | 38 +++++++++++++++++++ ...6-07-23-local-primary-ci-before-push.zh.md | 38 +++++++++++++++++++ .agents/skills/dsh-pre-push-checks/SKILL.md | 26 ++++++++----- .../dsh-pre-push-checks/agents/openai.yaml | 2 +- AGENTS.md | 4 +- docs/development.i18n.yaml | 4 +- docs/development.md | 13 ++++--- docs/development.zh.md | 13 ++++--- lefthook.yml | 8 ++-- package.json | 1 + packages/client/AGENTS.md | 2 +- scripts/run-gates.ts | 2 +- 18 files changed, 132 insertions(+), 37 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md create mode 100644 .agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index 5e1db16e52..b42d84bbac 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -2,7 +2,7 @@ Status: implemented -The hook/CI symmetry in this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md); CI remains the exhaustive enforcement path. +[Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) restores hook/CI symmetry for the primary Node inventory. [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) continues to own the pre-commit design. ## Problem diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 87b1c0847b..4733eabac6 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -2,7 +2,7 @@ Status: implemented -The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. +[Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) now owns the local-hook contract: pre-push selects the same primary inventory as CI. The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. ## Problem diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index 361f7a0bd0..7e6cb7f35e 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-fast-local-git-hooks.md: a07af1cd424c86f7fa80ea946cd5012362cc66eb -2026-07-22-fast-local-git-hooks.zh.md: 78d4ea8980476609a9140737a75152eba123b308 +2026-07-22-fast-local-git-hooks.md: 4a504c6c4f5816f00be6d86ddfbcb93baf0d7768 +2026-07-22-fast-local-git-hooks.zh.md: 792e035bc5761fc044cf231aff3bb59f31a47659 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md index a07af1cd42..4a504c6c4f 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-22-fast-local-git-hooks.zh.md) +> **Superseded for pre-push:** [Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) replaces the typecheck-only publication checkpoint with the exact primary CI inventory. The fast pre-commit decision remains in force; the pre-push design below records the policy this repository no longer uses. + ## Problem An agent already runs the tests and checks that exercise its change, while commit, push, and CI can each repeat increasingly broad subsets of the same work. A full pre-push suite therefore delays every publication, amplifies unrelated local flakes, and gives no new signal when CI immediately runs the exhaustive matrix again. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index 78d4ea8980..792e035bc5 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-22-fast-local-git-hooks.md) | 中文 +> **pre-push 部分已被取代:**[推送前本地运行主 CI](2026-07-23-local-primary-ci-before-push.md)以精确的主 CI 清单取代仅运行类型检查的发布检查点。快速 pre-commit 的决策继续有效;下文的 pre-push 设计记录了本仓库不再采用的策略。 + ## 问题 agent(智能体)已经会运行能够覆盖自身改动的测试和检查,而提交、推送与 CI 可能分别重复其中范围越来越广的子集。因此,全量 pre-push 套件会拖慢每次推送,放大与当前改动无关的本地偶发失败,而且 CI 紧接着再次运行完整矩阵时不会提供新信号。 diff --git a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml new file mode 100644 index 0000000000..e82aad513f --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.i18n.yaml @@ -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-23-local-primary-ci-before-push.md: 9d7f4d5a5aae428a003b1cbbb8911e7ad7c50831 +2026-07-23-local-primary-ci-before-push.zh.md: 3b58a828bd12d0149d8c4101d8665ee15b9fffc2 diff --git a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md new file mode 100644 index 0000000000..9d7f4d5a5a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.md @@ -0,0 +1,38 @@ +# Agent Note: Local primary CI before push + +Status: implemented + +English | [中文](2026-07-23-local-primary-ci-before-push.zh.md) + +## Problem + +Hosted CI can become unavailable before repository code executes because of account, billing, quota, or runner failures. A typecheck-only publication hook then permits a remote branch update without coverage, snapshot, documentation, build, package, or built-entrypoint evidence precisely when the hosted workflow cannot supply that signal. + +Focused checks remain the right feedback loop during implementation, but their selection depends on the author correctly predicting every affected contract. Publication needs one complete, mechanically owned local baseline that does not depend on the hosted control plane starting a job. + +## Decision + +[lefthook.yml](../../../../lefthook.yml) keeps pre-commit focused on staged lint, whitespace, and vendored-source metadata. Pre-push invokes `pnpm run check:pre-push` and blocks publication on any failure. + +The `check:pre-push` package script selects the `pre-push` mode in [scripts/run-gates.ts](../../../../scripts/run-gates.ts). Both `pre-push` and `ci-primary` return the same `ciPrimaryGates()` inventory, so the hook and the primary Node CI job cannot drift through separately maintained command lists. Build consumers retain their explicit scheduler dependencies, and `DSH_GATE_CONCURRENCY` remains the resource-control seam for constrained hosts. + +Authors still run focused checks while iterating. They do not run the full aggregate immediately before a normal push because the hook owns that one exhaustive local execution. A hook failure is fixed or reported as a blocker; bypass requires explicit approval. + +This contract is equivalent to the keyless primary Node CI aggregate on the current host. It does not claim the supported-version or operating-system matrix, Python SDK, real-provider, native, or sandbox workflow signals that require their own environments. + +## Supersedes + +This decision supersedes the pre-push half of [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). Its staged pre-commit design remains in force. It also restores the local publication role described by [Parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md) without reviving a second gate inventory. + +## Alternatives considered + +- **Rely on restoring hosted CI availability** — repairs the immediate administrative failure but leaves publication without a baseline during the next control-plane or runner outage. +- **Wire pre-push to `check:all`** — reuses a broad local command, but that inventory intentionally differs from the primary CI contract and would make “CI equivalent” inaccurate. +- **Copy the CI commands into `lefthook.yml`** — makes the hook visibly comprehensive but creates a second inventory that can drift whenever CI changes. +- **Keep typecheck-only pre-push and require a manual command during outages** — preserves low latency but relies on every author noticing the outage and remembering an exceptional procedure before each update. + +## Consequences + +Every normal push pays the primary CI aggregate's wall time and may be blocked by a repository-wide local failure unrelated to the outgoing diff. In return, every published revision has observed coverage, snapshots, documentation, build, package, and built-entrypoint evidence from one shared inventory even when hosted jobs never start. + +The result is local evidence, not a substitute for unavailable remote environments. Pull requests and handoffs report hosted billing, provider, platform, and pending states separately instead of presenting a successful macOS pre-push run as a green GitHub matrix. diff --git a/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md new file mode 100644 index 0000000000..3b58a828bd --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-local-primary-ci-before-push.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 推送前本地运行主 CI + +Status: implemented + +[English](2026-07-23-local-primary-ci-before-push.md) | 中文 + +## 问题 + +托管 CI 可能会因账户、计费、配额或运行器故障,在仓库代码开始执行之前就不可用。此时,仅运行类型检查的发布钩子会允许更新远端分支,却缺少覆盖率、快照、文档、构建、包(package)和构建后入口点的证据;恰恰这时,托管工作流无法提供这些信号。 + +实现期间,聚焦检查仍是正确的反馈循环,但检查选择取决于作者是否正确预判每项受影响的契约。发布需要一套由机制统一维护的完整本地基线,且不依赖托管控制平面能否启动作业。 + +## 决策 + +[lefthook.yml](../../../../lefthook.yml) 让 pre-commit 集中处理暂存文件 lint、空白错误和 vendor 源码元数据。Pre-push 调用 `pnpm run check:pre-push`,任何检查失败都会阻止发布。 + +`check:pre-push` 包脚本从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `pre-push` 模式。`pre-push` 和 `ci-primary` 都返回同一份 `ciPrimaryGates()` 清单,因此钩子与主 Node CI 作业不会因分别维护命令列表而产生漂移。构建产物消费方仍保留对调度器的显式依赖关系,`DSH_GATE_CONCURRENCY` 仍是资源受限主机的资源控制 seam。 + +作者在迭代时仍运行聚焦检查。正常推送前不立即运行全量聚合,因为钩子负责这一次全面的本地执行。钩子失败必须修复或报告为阻塞项;绕过钩子需要明确批准。 + +本契约等同于当前主机上的 keyless 主 Node CI 聚合。它不代表已经取得受支持版本矩阵或操作系统矩阵、Python SDK、真实模型提供方、原生构建或沙箱工作流的信号;这些信号需要各自的环境才能取得。 + +## 取代关系 + +本决策取代[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)中有关 pre-push 的部分。其中面向暂存文件的 pre-commit 设计继续有效。它还恢复了[并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)中描述的本地发布职责,但没有重新引入第二份门禁清单。 + +## 考虑过的替代方案 + +- **依靠恢复托管 CI 可用性**——可以修复当前的管理性故障,但下一次控制平面或运行器中断时,发布流程仍没有基线。 +- **将 pre-push 接入 `check:all`**——能够复用一条广泛的本地命令,但其清单有意不同于主 CI 契约,会使「等同于 CI」的表述不准确。 +- **将 CI 命令复制到 `lefthook.yml`**——能够直观展示钩子的全面性,但会创建第二份清单,并在每次 CI 变更时产生漂移。 +- **保留仅运行类型检查的 pre-push,并要求中断期间手动运行命令**——能够维持低延迟,但依赖每位作者发现中断,并在每次更新前记得执行特殊流程。 + +## 结果 + +每次正常推送都要承担主 CI 聚合的实际耗时,也可能被与待推送 diff 无关的全仓本地失败阻塞。相应地,即使托管作业从未启动,每个已发布版本仍有一套由共享清单实际运行得出的覆盖率、快照、文档、构建、包和构建后入口点证据。 + +该结果只是本地证据,不能代替不可用的远端环境。PR(Pull Request)和交接会分别报告托管服务计费、提供方、平台与待处理状态,而不会把一次成功的 macOS pre-push 运行表述成 GitHub 矩阵已通过。 diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 31f82eed94..65ab35f9a3 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -1,11 +1,11 @@ --- name: dsh-pre-push-checks -description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite. +description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select focused implementation evidence and preserve the mandatory primary-CI pre-push gate. --- # DSH Pre-Push Checks -Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix. +Use this skill to run relevant implementation evidence once and the complete local publication baseline once before a `deepseek-harness` push. Pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push invokes `pnpm run check:pre-push`, which selects the same primary Node inventory as `pnpm run check:ci`. Hosted CI still owns platform- and provider-specific evidence. ## Inspect the outgoing change @@ -27,15 +27,15 @@ If the branch has no upstream or that range is not meaningful for the stack, com ## Select relevant evidence -There is no universal local baseline beyond the hooks. Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression; add broader checks only for surfaces the diff actually reaches. +Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression. Run that evidence while iterating; the hook supplies the universal publication baseline. -- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it. +- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to pre-push unless the change is genuinely cross-cutting or the user requests it earlier. - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it. - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output. - **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke. - **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets. -Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook. +Do not manually repeat a passing check merely because commit or push follows. In particular, do not run `check:pre-push` immediately before a normal push and then repeat the same aggregate in the hook. ### Focus unit coverage on the affected source @@ -60,13 +60,19 @@ pnpm exec vitest related packages///src/.ts \ `vitest related` cannot discover behavior reached only through configuration, dynamic loading, subprocesses, workers, built artifacts, or external providers; select those owning tests explicitly. Do not use `--passWithNoTests`, lower coverage thresholds, or narrow `--coverage.include` merely to hide an uncovered affected file. If a selected package scope fails because one focused test does not cover it, add its other relevant owning tests or narrow the source scope only when the excluded modules cannot be affected by the change. -## Full local rehearsal +## Mandatory publication gate -Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate. +The normal push runs the complete keyless primary Node inventory through Lefthook: + +```sh +pnpm run check:pre-push +``` + +Invoke the command directly only when the user requests a rehearsal independent of publication or when diagnosing the hook itself. Add the relevant `pnpm run test:e2e` target when credentials are available and behavior depends on a real provider; real-API e2e is not part of the keyless primary inventory. ## Handle failures -If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs. +If a relevant check or the publication gate fails, stop and fix or explain the blocker. Do not push and hope CI differs. If a failure looks environment-specific, prove it: @@ -77,9 +83,9 @@ If a failure looks environment-specific, prove it: ## Push procedure -1. Run the selected relevant checks once. +1. Run the selected focused checks once during implementation. 2. Commit normally and inspect any files changed by the pre-commit fixer before continuing. -3. Push normally so the incremental typecheck hook runs. +3. Push normally so the complete primary Node hook runs once. 4. Verify the remote ref matches local `HEAD`. ```sh diff --git a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml index 4a38ea4da8..be0ef1f114 100644 --- a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml +++ b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "DSH Pre-Push Checks" - short_description: "Run the relevant DeepSeek Harness checks before push" + short_description: "Run focused and primary CI checks before push" default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch." diff --git a/AGENTS.md b/AGENTS.md index 8007f32b90..2aee7c5af9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,10 +73,10 @@ When required `gh`, `pnpm`, build, test, or generator commands fail because the ### Run relevant checks locally -Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. +Agents MUST run focused checks while implementing; normal pre-push runs `check:pre-push`, the same primary Node inventory as `check:ci`. Select focused evidence with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. - Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior. -- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. +- Let the hook run the full aggregate once; never bypass a failure without explicit approval. Remote CI owns the platform and provider matrix. - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). ## Secrets / .env diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 40828094ce..cfea7907d5 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 4294038e40aa774a006874e6641ca63eea44beeb -development.zh.md: 1f07c95dd60d0554b945c29e6e3ba8bc6ca9841a +development.md: 2496afa3a5b27efdade4a7c6e66e54b55a25b075 +development.zh.md: 9b44eb1c2679be395f7a19882dcdd38261833a06 diff --git a/docs/development.md b/docs/development.md index 4294038e40..2496afa3a5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,20 +75,20 @@ DEEPSEEK_BASE_URL=https://... # optional ## Git hooks -lefthook is configured in `lefthook.yml` as a fast local checkpoint: +lefthook is configured in `lefthook.yml` with fast commit-local checks and a comprehensive publication check: - `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. -- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates). +- `pre-push` invokes `pnpm run check:pre-push`, which selects the same primary Node gate inventory as `pnpm run check:ci` from `scripts/run-gates.ts`. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. -The hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix. +Contributors run [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) while iterating. A normal push runs the complete local primary aggregate once and stops publication on any failure; do not run the same aggregate immediately before pushing. -Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction. +The pre-push result covers the keyless primary Node lane on the current host. It does not replace the supported-version and platform matrix, Python SDK tests, real-API e2e, or sandbox workflows. `pnpm run check:all` remains a broad opt-in development inventory; it is independent of both Git hooks and is not the publication contract. ## CI gates -The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. +The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. `check:pre-push` and the primary CI job share one gate inventory; the separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. ## Daily commands @@ -98,7 +98,8 @@ Use these from the repo root: pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks +pnpm run check:pre-push # primary Node CI inventory; runs automatically before push +pnpm run check:all # broad opt-in development gate set; not wired to Git hooks pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix diff --git a/docs/development.zh.md b/docs/development.zh.md index 1f07c95dd6..9b44eb1c26 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -75,20 +75,20 @@ DEEPSEEK_BASE_URL=https://... # optional ## Git 钩子 -lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: +lefthook 在 `lefthook.yml` 中配置了快速的提交级检查和全面的发布检查: - `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; -- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。 +- `pre-push` 调用 `pnpm run check:pre-push`;该命令从 `scripts/run-gates.ts` 中选择与 `pnpm run check:ci` 相同的主 Node 门禁清单。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 -这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。 +贡献者在迭代过程中运行[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally)。正常推送会在本地完整运行一次主 CI 聚合,并在任何检查失败时阻止发布;不要在推送前立即重复运行同一个聚合。 -贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。 +pre-push 的结果覆盖当前主机上的 keyless 主 Node lane,但不能代替受支持版本与平台矩阵、Python SDK 测试、真实 API e2e 或沙箱工作流。`pnpm run check:all` 仍是一份广泛的可选开发检查清单;它独立于两个 Git 钩子,也不属于发布契约。 ## CI 门禁 -keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 +keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。`check:pre-push` 与主 CI job 共用一份门禁清单;单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 ## 日常命令 @@ -98,7 +98,8 @@ keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若 pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks +pnpm run check:pre-push # primary Node CI inventory; runs automatically before push +pnpm run check:all # broad opt-in development gate set; not wired to Git hooks pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix diff --git a/lefthook.yml b/lefthook.yml index cf2e6bb11d..d566de8b1c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,5 +1,5 @@ -# Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full -# repository-wide gate matrix. +# Git hooks (lefthook). Pre-commit keeps commit-local checks fast; pre-push +# runs the primary Node CI inventory before publication. # Install: `pnpm exec lefthook install` (runs automatically via postinstall). pre-commit: @@ -19,5 +19,5 @@ pre-commit: pre-push: jobs: - - name: typecheck - run: node_modules/.bin/tsc -b --pretty false + - name: primary CI + run: pnpm run check:pre-push diff --git a/package.json b/package.json index 38db3a2ac7..7345fcd173 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:web": "npm run build:web && vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", + "check:pre-push": "tsx scripts/run-gates.ts pre-push", "check:ci": "tsx scripts/run-gates.ts ci-primary", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index d3011ed85f..e858d9ffbc 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -66,7 +66,7 @@ Run the narrowest rung that covers what you touched; escalate only when the chan 1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck. 2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`). -3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit. +3. **Before every push** — the normal hook runs `pnpm run check:pre-push` (the repo-wide primary CI inventory). Do not invoke it manually immediately before pushing. If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 896ffacfd1..0399e3249f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -168,6 +168,7 @@ function nodeOptions(...options: string[]): string { function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': + case 'pre-push': return ciPrimaryGates() case 'ci-static': return ciStaticGates() @@ -190,7 +191,6 @@ function gatesForMode(selected: Mode): Gate[] { return ciWindowsObservationalGates() case 'node-compat': return nodeCompatGates() - case 'pre-push': return [] case 'check-all': return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), From f062ba8fcdbdb6286ddf8df5bd1ecf21c0ade8f3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:30:07 +0800 Subject: [PATCH 7/8] fix(gui): keep the label fade and pin the rail icons to one axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two polish defects from the headless verification pass: - .newSessionLabel/.footLabel declared their own transition shorthand, which replaced .wide's opacity fade entirely (shorthands do not merge) — the labels vanished instantly while everything else faded. The fade is restated alongside max-width. - The rail icons drifted off a common vertical axis: the 56px track holds a 55px content box (1px column border), and the capsule rows kept their 1px border-width in the collapsed state, shaving their content to 23px and pushing the search icon 2px right. Collapsed capsules now zero the border-width (transitioning border, not border-color), the root gives back the border pixel via asymmetric padding, and every surviving row centers its icon — five glyphs on the track's 28px center. --- .../src/client/SidebarRoot.module.css | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index c580d47b75..844c406e35 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -17,8 +17,11 @@ transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out); } +/* The 56px track paints a 1px column border, leaving a 55px content box; + the right padding gives back that pixel so every row holds exactly 24px + of content and all five glyphs share the track's 28px center axis. */ .root.collapsed { - padding-top: 14px; + padding: 14px 15px 6px 16px; } /* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and @@ -55,10 +58,16 @@ margin var(--ds-transition-duration-slow) var(--ds-ease-in-out); } +/* Rail axis: every collapsed row centers its surviving icon in the 55px + content box (56px track minus the column border), putting all five glyphs + on one vertical line; capsule border-widths zero out so they stop taking + layout space. While wide content is still fading its flex:1 spans keep + absorbing the free space, so justify-content only takes over at unmount. */ .collapsed .logoRow { height: 24px; padding: 0; margin-bottom: 8px; + justify-content: center; } /* Brand group (figma I133:7632): fish + wordmark ride the text ink @@ -144,7 +153,7 @@ 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), + border var(--ds-transition-duration-slow) var(--ds-ease-in-out), background-color 200ms var(--ds-ease-in-out); } @@ -152,12 +161,14 @@ background: var(--dsw-alias-button-floating-hover); } +/* Border width zeroes with the color: a leftover 1px border inside the + border-box would shave the rail row's content below 24px. */ .collapsed .newSession { height: 24px; padding: 0; margin-bottom: 8px; gap: 0; - border-color: transparent; + border-width: 0; background: transparent; } @@ -169,7 +180,10 @@ max-width: 200px; overflow: hidden; white-space: nowrap; - transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); + /* Opacity restated: this shorthand would otherwise replace .wide's fade. */ + transition: + max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out), + opacity 200ms var(--ds-ease-in-out); } .collapsed .newSessionLabel { @@ -201,6 +215,7 @@ height: 24px; padding-left: 0; margin-bottom: 8px; + justify-content: center; } .sectionLabel { @@ -236,7 +251,7 @@ 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), + border var(--ds-transition-duration-slow) var(--ds-ease-in-out), background-color 200ms var(--ds-ease-in-out); } @@ -244,13 +259,16 @@ --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); } +/* Border width zeroes with the color (see .collapsed .newSession); the row + centers its surviving icon once the input unmounts. */ .collapsed .search { height: 24px; padding: 0; margin-bottom: 8px; gap: 0; - border-color: transparent; + border-width: 0; background: transparent; + justify-content: center; } /* The capsule's leading icon, upgraded to the rail's search control. While @@ -398,7 +416,10 @@ max-width: 120px; overflow: hidden; white-space: nowrap; - transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); + /* Opacity restated: this shorthand would otherwise replace .wide's fade. */ + transition: + max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out), + opacity 200ms var(--ds-ease-in-out); } .collapsed .footLabel { From dd7d198c0d41d57b698f2dac21874bf541d3b671 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:37:54 +0800 Subject: [PATCH 8/8] Revert "fix(gui): keep the label fade and pin the rail icons to one axis" This reverts commit a4ae2f6c21c1eeed2495481a55a39762f4e64ccb. --- .../src/client/SidebarRoot.module.css | 35 ++++--------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 844c406e35..c580d47b75 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -17,11 +17,8 @@ transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* The 56px track paints a 1px column border, leaving a 55px content box; - the right padding gives back that pixel so every row holds exactly 24px - of content and all five glyphs share the track's 28px center axis. */ .root.collapsed { - padding: 14px 15px 6px 16px; + padding-top: 14px; } /* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and @@ -58,16 +55,10 @@ margin var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* Rail axis: every collapsed row centers its surviving icon in the 55px - content box (56px track minus the column border), putting all five glyphs - on one vertical line; capsule border-widths zero out so they stop taking - layout space. While wide content is still fading its flex:1 spans keep - absorbing the free space, so justify-content only takes over at unmount. */ .collapsed .logoRow { height: 24px; padding: 0; margin-bottom: 8px; - justify-content: center; } /* Brand group (figma I133:7632): fish + wordmark ride the text ink @@ -153,7 +144,7 @@ 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 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); } @@ -161,14 +152,12 @@ background: var(--dsw-alias-button-floating-hover); } -/* Border width zeroes with the color: a leftover 1px border inside the - border-box would shave the rail row's content below 24px. */ .collapsed .newSession { height: 24px; padding: 0; margin-bottom: 8px; gap: 0; - border-width: 0; + border-color: transparent; background: transparent; } @@ -180,10 +169,7 @@ max-width: 200px; overflow: hidden; white-space: nowrap; - /* Opacity restated: this shorthand would otherwise replace .wide's fade. */ - transition: - max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out), - opacity 200ms var(--ds-ease-in-out); + transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .collapsed .newSessionLabel { @@ -215,7 +201,6 @@ height: 24px; padding-left: 0; margin-bottom: 8px; - justify-content: center; } .sectionLabel { @@ -251,7 +236,7 @@ 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 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); } @@ -259,16 +244,13 @@ --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); } -/* Border width zeroes with the color (see .collapsed .newSession); the row - centers its surviving icon once the input unmounts. */ .collapsed .search { height: 24px; padding: 0; margin-bottom: 8px; gap: 0; - border-width: 0; + border-color: transparent; background: transparent; - justify-content: center; } /* The capsule's leading icon, upgraded to the rail's search control. While @@ -416,10 +398,7 @@ max-width: 120px; overflow: hidden; white-space: nowrap; - /* Opacity restated: this shorthand would otherwise replace .wide's fade. */ - transition: - max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out), - opacity 200ms var(--ds-ease-in-out); + transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .collapsed .footLabel {