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', () => {