From 75d37654cb5858ab87060285f1e2950ef0809abe Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:30:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(gui):=20address=20review=20=E2=80=94=20stal?= =?UTF-8?q?e=20smoke=20case,=20tooltip=20trigger=20overlap,=20collapse=20c?= =?UTF-8?q?ontract=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The real-browser collapse smoke tracked the old chrome: visible HARNESS text (the wordmark svg is aria-hidden now), an 'Expand sidebar' label (renamed 'Open sidebar'), a 300px settle (default is 280), and an immediate focus assert (rail search defers focus past the slide). The case now tracks the brand span, polls the deferred focus, and uses the current labels and width. - Tooltip treated hover and focus as one trigger: leaving with the mouse dropped the bubble of a still-focused anchor (and vice versa). The two triggers are tracked independently; the bubble hides only after both clear. Spec pins both orders. - The ui-sidebar README and the bilingual collapse note still described the retired geometry morph; both now state the slide + crossfade contract, the fixed-width (never-conceding) sidebar, and the rail's whale-mark/tooltip chrome. --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 4 ++-- ...26-07-22-collapsed-sidebar-control-rail.md | 4 ++-- ...07-22-collapsed-sidebar-control-rail.zh.md | 4 ++-- apps/web/tests/smoke-fixture.e2e.ts | 20 ++++++++-------- packages/client/ui-layout/README.md | 2 +- packages/client/ui-primitives/src/Tooltip.tsx | 17 +++++++++----- .../ui-primitives/tests/tooltip.spec.tsx | 23 +++++++++++++++++++ packages/client/ui-sidebar/README.md | 2 +- 8 files changed, 53 insertions(+), 23 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 9d8ccb1790..19e9446f50 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: e959eef37a9e9c0fea79b82ff970daddd9257609 -2026-07-22-collapsed-sidebar-control-rail.zh.md: 7f6d6529a8aa4a655a1d3292e7f41bfb822f05a3 +2026-07-22-collapsed-sidebar-control-rail.md: 940fcabf126941cc0e411b01c337e45831e442aa +2026-07-22-collapsed-sidebar-control-rail.zh.md: 70ace36fafcb28aa714000262e31c8555d394854 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 e959eef37a..940fcabf12 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 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. +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 sidebar track is fixed-width in the solver — open or collapsed it never concedes to viewport pressure (only details shrinks, then auto-closes) — and the rail retains its right border while the stored expanded width remains untouched. `AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`. -`SidebarRoot` reads the owner `collapsed` prop and morphs in place rather than swapping renders: the four control rows persist into the rail — expand toggle, new session, new workspace, search, in the same top-down order as their expanded rows — animating their geometry (heights, paddings, margins, capsule borders) on the same curve, each aligned with its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box). Wide-only content (brand, labels, input, session tree) cross-fades out over 200ms, stays mounted while the collapse animates, and unmounts once the 300ms settle passes — dropping the sessions subscription and leaving the rendered and accessibility trees. The search query lives with the root and survives the round trip. +`SidebarRoot` reads the owner `collapsed` prop and transitions as a slide + crossfade: the expanded content freezes at its width (inline style) and fades out in place over 150ms while the sliding grid column clips it — nothing reflows mid-slide. At settle the wide-only content (brand, labels, input, session tree) unmounts — dropping the sessions subscription and leaving the rendered and accessibility trees — and the control rows snap to the rail (open toggle, new session, new workspace, search, the same top-down order as their expanded rows) fading in as the slide ends. Each rail control keeps its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box after the slide), carries a tooltip, and the toggle rests as the whale mark with the panel icon on hover. 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 7f6d6529a8..70ace36faf 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` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 +布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。侧边栏轨道在求解器中是定宽的——无论展开还是折叠都不向视口压力让步(只有 details 会收缩、继而自动关闭);控制栏保留右侧边框,已存储的展开宽度保持不变。 `AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out` 配 `--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。 -`SidebarRoot` 读取 owner 的 `collapsed` 属性,原地 morph 而非切换渲染:四个控件行持续存在并演变为控制栏——展开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致——几何(行高、内边距、外边距、胶囊边框)走同一条曲线动画,行为与展开态对应控件对齐(搜索图标会展开侧边栏并聚焦搜索框)。宽态专属内容(品牌标识、文字标签、输入框、会话树)以 200ms 交叉淡出,折叠动画期间保持挂载,300ms settle 后卸载——随之退订会话列表并离开渲染树与可访问性树。搜索关键词由根组件持有,折叠往返后保留。 +`SidebarRoot` 读取 owner 的 `collapsed` 属性,过渡是滑动 + 交叉淡变:展开内容以内联样式冻结在原宽度、150ms 原地淡出,滑动中的网格列裁切它——滑动途中不发生任何重排。settle 时宽态专属内容(品牌标识、文字标签、输入框、会话树)卸载——随之退订会话列表并离开渲染树与可访问性树——控件行落位到控制栏(打开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致),随滑动结束淡入。每个控制栏控件保持与展开态对应控件一致的行为(搜索图标展开侧边栏并在滑动结束后聚焦搜索框)并带 tooltip;开关静止时显示鲸鱼标,悬停切换为面板图标。搜索关键词由根组件持有,折叠往返后保留。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index baa33e56ef..9f3998932f 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -149,25 +149,27 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( const settledTrack = async (px: string): Promise => { await expect.poll(firstTrack, { timeout: 2000 }).toBe(px) } + // The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome. + const brand = () => page.locator('[class*="brand"]').count() 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) + expect(await brand()).toBe(1) await settledTrack('56px') - await expect.poll(() => page.locator('text=HARNESS').count(), { timeout: 2000 }).toBe(0) - for (const name of ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) { + await expect.poll(brand, { timeout: 2000 }).toBe(0) + for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) { await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true) } - await page.getByRole('button', { name: 'Expand sidebar' }).click() - await settledTrack('300px') + await page.getByRole('button', { name: 'Open sidebar' }).click() + await settledTrack('280px') 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') + await settledTrack('280px') + // Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it. + await expect.poll(() => page.evaluate(() => + (document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search') }) it('renders file tool rows and expands fixture reasoning from either click target', async () => { diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 6cb5fa29a4..9c31e4cc7a 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 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. +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. The sidebar is fixed-width (it never concedes to viewport pressure — only details shrinks, then auto-closes); 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-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 21191aefb3..f62a397535 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -34,11 +34,14 @@ interface AnchorProps { export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement }) { const anchor = useRef(null) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + // Hover and focus are independent triggers: the bubble hides only after + // BOTH clear (hovering away from a focused anchor must not drop it). + const triggers = useRef({ hover: false, focus: false }) // Disabling mid-hover (e.g. clicking a rail control expands the sidebar) // must drop an already-visible bubble: no mouseleave fires. useEffect(() => { - if (disabled) setPos(null) + if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) } }, [disabled]) const show = () => { @@ -51,16 +54,18 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { ? { x: r.right + 10, y: r.top + r.height / 2 } : { x: r.left + r.width / 2, y: r.bottom + 8 }) } - const hide = () => { setPos(null) } + const hide = () => { + if (!triggers.current.hover && !triggers.current.focus) setPos(null) + } return ( <> {cloneElement(children, { ref: anchor, - onMouseEnter: (e) => { children.props.onMouseEnter?.(e); show() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); hide() }, - onFocus: (e) => { children.props.onFocus?.(e); show() }, - onBlur: (e) => { children.props.onBlur?.(e); hide() }, + onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() }, + onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, + onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {pos !== null && ( diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index c71124040d..3b3af8373c 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -81,6 +81,29 @@ describe('Tooltip', () => { expect(screen.getByRole('tooltip')).toBeTruthy() }) + it('keeps the bubble while either hover or focus is still active', () => { + render( + + + , + ) + const anchor = screen.getByText('anchor') + // Focused AND hovered: leaving with the mouse must not drop the bubble. + fireEvent.focus(anchor) + fireEvent.mouseEnter(anchor) + fireEvent.mouseLeave(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() + fireEvent.blur(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + // Symmetric: blurring while still hovered keeps it, mouseleave ends it. + fireEvent.mouseEnter(anchor) + fireEvent.focus(anchor) + fireEvent.blur(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() + fireEvent.mouseLeave(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + }) + it('drops an already-visible bubble when disabled flips mid-hover', () => { const { rerender } = render( diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 33cdeb756d..7529bfe89c 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. 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). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. 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.