feat(gui): morph the sidebar collapse instead of swapping renders

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.
This commit is contained in:
imccyu
2026-07-23 13:03:32 +08:00
parent 5da8e3b787
commit 698f46b0c9
8 changed files with 413 additions and 217 deletions

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
/** 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 (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId="workspace"
onSelect={() => { setOpen(false) }}
align="end"
anchor={(
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
onClick={() => { setOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
)
}
/** 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<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
/** Live search filter owned by the root (the query outlives the tree). */
query: string
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
const list = useSessions((s) => s)
// Wave-2 seam: row highlight expects `current` on the sessions list
// snapshot (sessions.current lives with the runtime sessions service).
const current = useSessions((s) => s.current)
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
const [query, setQuery] = useState('')
const rows = useMemo(
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
[list, expandedProjects, expandedSessions, query],
)
const [menuOpen, setMenuOpen] = useState(false)
const now = Date.now()
// Presentational lookup (not tree derivation): the group holding the
@@ -65,59 +95,7 @@ function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: Sidebar
}
return (
<div className={css.listArea}>
<div className={css.sectionHeader}>
<span className={css.sectionLabel}>WorkSpace</span>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId="workspace"
onSelect={() => { setMenuOpen(false) }}
align="end"
anchor={(
<button
type="button"
className={css.iconButton}
aria-label="Group by"
onClick={() => { setMenuOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
<button
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
</div>
<label className={css.search}>
<IconSearchOutline16 size={14} />
<input
className={css.searchInput}
type="text"
placeholder="Search name, keywords..."
value={query}
autoFocus={autoFocusSearch}
onChange={(e) => { setQuery(e.target.value) }}
/>
{query !== '' && (
<button
type="button"
className={css.clearButton}
aria-label="Clear search"
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
)}
</label>
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
{rows.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
@@ -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<HTMLInputElement | null>(null)
// Wide content stays mounted while the collapse animates (fading via
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
const [settled, setSettled] = useState(collapsed)
useEffect(() => {
if (!collapsed) { setSettled(false); return }
const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS)
return () => { window.clearTimeout(timer) }
}, [collapsed])
const wide = !collapsed || !settled
// Rail search = expand + land in the search box: the flag arms before the
// expand toggle; once expanded the input is mounted and takes focus.
const [searchOnExpand, setSearchOnExpand] = useState(false)
useEffect(() => {
if (!collapsed && searchOnExpand) 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 (
<div className={clsx(css.root, css.collapsed)}>
return (
<div className={clsx(css.root, collapsed && css.collapsed)}>
<div className={css.logoRow}>
{wide && (
<span className={clsx(css.brand, css.wide)}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
)}
<button
type="button"
className={css.iconButton}
aria-label="Expand sidebar"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
<button
type="button"
className={css.iconButton}
aria-label="New session"
onClick={() => { onCreate() }}
>
<IconNewChatOutline16 />
</button>
<button
type="button"
className={css.iconButton}
aria-label="Search sessions"
onClick={() => { setSearchOnExpand(true); onToggleSidebar() }}
>
<IconSearchOutline16 />
</button>
</div>
<button
type="button"
className={css.newSession}
aria-label="New session"
onClick={() => { onCreate() }}
>
<IconNewChatOutline16 size={14} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
</button>
<div className={css.sectionHeader}>
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
{wide && <GroupByMenu />}
<button
type="button"
className={css.iconButton}
@@ -203,44 +202,51 @@ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggle
>
<IconProjectAddOutline16 />
</button>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
</div>
</div>
)
}
return (
<div className={css.root}>
<div className={css.headerBlock}>
<div className={css.logoRow}>
<span className={css.brand}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Collapsed: the icon is the rail's search control. */}
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
tabIndex={collapsed ? 0 : -1}
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
>
<IconSearchOutline16 size={14} />
</button>
{wide && (
<input
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { setQuery(e.target.value) }}
/>
)}
{wide && query !== '' && (
<button
type="button"
className={css.iconButton}
aria-label="Collapse sidebar"
onClick={() => { onToggleSidebar() }}
className={clsx(css.clearButton, css.wide)}
aria-label="Clear search"
onClick={() => { setQuery('') }}
>
<IconPanelLeftOutline16 />
<IconCloseFill14 />
</button>
</div>
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
)}
</div>
<SidebarBody useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} autoFocusSearch={searchOnExpand} />
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
both states while the tree itself is wide-only. */}
<div className={css.listArea}>
{wide && <SessionTree useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} query={query} />}
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
<span>Settings</span>
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
</div>
</div>
)

View File

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