Merge remote-tracking branch 'origin/master' into worktree/web-session-titles

# Conflicts:
#	.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/selection-survival.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-layout/tests/service.spec.ts
#	packages/client/ui-sidebar/tests/apply.spec.tsx
#	packages/client/ui-sidebar/tests/store.spec.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/client/web/src/app.tsx
#	packages/client/web/tests/boot.spec.tsx
#	packages/host/runtime/README.md
#	packages/host/runtime/tests/host-runtime.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-23 18:39:48 +08:00
285 changed files with 11631 additions and 6072 deletions

View File

@@ -1,8 +1,12 @@
# @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. 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 — 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<ClientContext>`.
`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.
There is no plugin store: rows derive in the component (`useMemo` over the `useSessions` snapshot + local expansion/search state) through the pure `deriveRows` in `tree.ts`.
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly).
## Model Experience

View File

@@ -38,7 +38,6 @@
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},

View File

@@ -1,39 +1,64 @@
/* Sidebar column (figma 133:7629): vertical stack, gap 8, padding 16/6,
sidebar fill + 1px right border painted by the layout column. Header block
(logo + New Session) and list area (section header + search + cells) carry
their own inner gaps per the style spec (1.2 / 1.3). */
/* Sidebar column (figma 133:7629): vertical stack, padding 16/6, sidebar
fill + 1px right border painted by the layout column. Collapse morphs in
place: the four control rows persist into the 56px rail (one icon each,
x-converged by the shrinking column), geometry rides the deepsuite curve
while wide-only content cross-fades 200ms; explicit margins own the
vertical rhythm in both states so every gap can transition. */
.root {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
padding: 6px 16px;
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Header block (figma 133:7630): logo row + New Session, gap 16, padBottom 12. */
.headerBlock {
flex: none;
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 12px;
.root.collapsed {
padding-top: 14px;
}
/* Logo row: 60px, brand mark left, collapse button right.
figma pad is (l,t,r,b)=(4,8,4,8) — horizontal 4, vertical 8. */
/* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and
unmounts once the collapse settles; remounts fade back in. */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
transition: opacity 200ms var(--ds-ease-in-out);
}
.collapsed .wide {
opacity: 0;
}
@keyframes wide-in {
from { opacity: 0; }
}
/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored —
the toggle is the rail's expand control and slides in with the right edge. */
.logoRow {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
height: 60px;
padding: 8px 4px;
margin-bottom: 16px;
box-sizing: border-box;
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .logoRow {
height: 24px;
padding: 0;
margin-bottom: 8px;
}
/* Brand group (figma I133:7632): fish + wordmark ride the text ink
@@ -79,13 +104,22 @@
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
transition:
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* New Session: 38px capsule (figma 133:7634). */
.collapsed .iconButton {
width: 24px;
height: 24px;
}
/* New Session: 38px capsule (figma 133:7634) morphing into the rail's plain
icon control — border and fill fade with the label. */
.newSession {
flex: none;
display: flex;
@@ -94,6 +128,7 @@
gap: 6px;
height: 38px;
padding: 8px 16px;
margin-bottom: 20px; /* former headerBlock padBottom 12 + root gap 8 */
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
@@ -103,65 +138,84 @@
font-weight: 510;
line-height: 22px;
cursor: pointer;
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background-color 200ms var(--ds-ease-in-out);
}
.newSession:hover {
background: var(--dsw-alias-button-floating-hover);
}
/* List area (figma 133:7640): section header + search + cells, gap 4.
Relative for the bottom fade overlay. */
.listArea {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 4px;
.collapsed .newSession {
height: 24px;
padding: 0;
margin-bottom: 8px;
gap: 0;
border-color: transparent;
background: transparent;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
.collapsed .newSession:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
session run, before the next project row. */
.batchGap {
flex: none;
height: 20px;
.newSessionLabel {
max-width: 200px;
overflow: hidden;
white-space: nowrap;
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons. */
.collapsed .newSessionLabel {
max-width: 0;
}
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons;
the right-anchored new-workspace button is the row's rail survivor. */
.sectionHeader {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
height: 36px;
padding-left: 12px;
margin-bottom: 4px;
box-sizing: border-box;
border-radius: 12px;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .sectionHeader {
height: 24px;
padding-left: 0;
margin-bottom: 8px;
}
.sectionLabel {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
line-height: 20px;
}
/* Search input: 38px capsule (figma 133:7649). Upstream binds a dedicated
design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped
alias — a component token pinned to the static scale mirrors it (ruled
compliant: indirect via custom property, upstream-variable equivalent). */
/* Search input: 38px capsule (figma 133:7649) morphing into the rail's
search control. Upstream binds a dedicated design-system variable (light
#F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token
pinned to the static scale mirrors it (ruled compliant: indirect via
custom property, upstream-variable equivalent). */
.search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
flex: none;
@@ -169,19 +223,64 @@
align-items: center;
gap: 8px;
height: 38px;
margin-bottom: 8px; /* + 4px area gap = 12px to the first cell (spec padB12) */
margin-bottom: 12px; /* former listArea gap 4 + own 8 (spec padB12 to the first cell) */
padding: 0 14px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
background: var(--dsh-search-input-fill);
color: var(--dsw-alias-label-caption);
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background-color 200ms var(--ds-ease-in-out);
}
:global(body[data-ds-dark-theme]) .search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
}
.collapsed .search {
height: 24px;
padding: 0;
margin-bottom: 8px;
gap: 0;
border-color: transparent;
background: transparent;
}
/* The capsule's leading icon, upgraded to the rail's search control. While
expanded it is decorative: pointer-events off so clicks reach the label
(native input focus); collapsed it becomes the hit target. */
.searchButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
padding: 0;
background: transparent;
pointer-events: none;
color: inherit;
}
.collapsed .searchButton {
pointer-events: auto;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
.collapsed .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchInput {
flex: 1;
min-width: 0;
@@ -212,6 +311,44 @@
color: var(--dsw-alias-label-secondary);
}
/* Tree seat: always mounted so the foot never moves; the tree content inside
is wide-only and clips while the column squeezes. */
.listArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
}
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
session run, before the next project row. */
.batchGap {
flex: none;
height: 20px;
}
/* Tree list: the only scrolling region. */
.list {
flex: 1;
@@ -229,20 +366,57 @@
font-size: 13px;
}
/* Foot: settings entry (figma 133:7668). */
/* Foot: settings entry (figma 133:7668). Left padding lands the 14px glyph
on the rail's icon axis when collapsed. */
.foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 29px;
margin: 10px 0;
margin: 18px 0 10px; /* former root gap 8 + own 10 above; root padBottom 6 below */
padding: 0 2px 0 6px;
border-radius: 12px;
cursor: pointer;
overflow: hidden;
color: var(--dsw-alias-label-primary);
transition:
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.foot:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.collapsed .foot {
gap: 0;
padding: 0 0 0 5px;
}
.footLabel {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .footLabel {
max-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.root,
.wide,
.logoRow,
.iconButton,
.newSession,
.newSessionLabel,
.sectionHeader,
.search,
.foot,
.footLabel {
transition: none;
animation: none;
}
}

View File

@@ -1,11 +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 — data and actions arrive through the
* inject surface; the tree store is subscribed via useTree, never derived in
* render.
* 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, useState } from 'react'
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo,
@@ -14,9 +22,13 @@ import {
Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarRootComponentProps } from './contract/slots.ts'
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.
@@ -24,17 +36,53 @@ 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) {
const rows = useTree((s) => s.rows)
const query = useTree((s) => s.query)
const groupBy = useTree((s) => s.groupBy)
const current = useCurrent()
const [menuOpen, setMenuOpen] = useState(false)
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
}
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
function GroupByMenu() {
const [open, setOpen] = useState(false)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId="workspace"
onSelect={() => { setOpen(false) }}
align="end"
anchor={(
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
onClick={() => { setOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
)
}
type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
/** Live search filter owned by the root (the query outlives the tree). */
query: string
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
const list = useSessions((s) => s)
// Wave-2 seam: row highlight expects `current` on the sessions list
// snapshot (sessions.current lives with the runtime sessions service).
const current = useSessions((s) => s.current)
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
const rows = useMemo(
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
[list, expandedProjects, expandedSessions, query],
)
const now = Date.now()
// Presentational lookup (not tree derivation): the group holding the
@@ -47,83 +95,7 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
}
return (
<div className={css.root}>
<div className={css.headerBlock}>
<div className={css.logoRow}>
<span className={css.brand}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
<button
type="button"
className={css.iconButton}
aria-label="Collapse sidebar"
onClick={() => { actions.toggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<button type="button" className={css.newSession} onClick={() => { actions.create() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
</div>
<div className={css.listArea}>
<div className={css.sectionHeader}>
<span className={css.sectionLabel}>WorkSpace</span>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId={groupBy}
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={() => { actions.create() }}
>
<IconProjectAddOutline16 />
</button>
</div>
<label className={css.search}>
<IconSearchOutline16 size={14} />
<input
className={css.searchInput}
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { tree.setQuery(e.target.value) }}
/>
{query !== '' && (
<button
type="button"
className={css.clearButton}
aria-label="Clear search"
onClick={() => { tree.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>
@@ -136,8 +108,8 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
<ProjectRowItem
row={row}
active={row.key === activeGroup}
onToggle={() => { tree.toggleProject(row.key) }}
onCreate={() => { actions.create(row.cwd) }}
onToggle={() => { setExpandedProjects((l) => toggled(l, row.key)) }}
onCreate={() => { onCreate(row.cwd) }}
/>
</Fragment>
)
@@ -147,17 +119,134 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
row={row}
selected={row.id === current}
now={now}
onOpen={() => { actions.open(row.id) }}
onToggle={() => { tree.toggleSession(row.id) }}
onOpen={() => { onOpen(row.id) }}
onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }}
/>
))}
</div>
<span className={css.fade} />
</div>
)
}
/**
* Render the sidebar column.
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
const searchInput = useRef<HTMLInputElement | null>(null)
// Wide content stays mounted while the collapse animates (fading via
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
const [settled, setSettled] = useState(collapsed)
useEffect(() => {
if (!collapsed) { setSettled(false); return }
const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS)
return () => { window.clearTimeout(timer) }
}, [collapsed])
const wide = !collapsed || !settled
// Rail search = expand + land in the search box: the flag arms before the
// expand toggle; once expanded the input is mounted and takes focus.
const [searchOnExpand, setSearchOnExpand] = useState(false)
useEffect(() => {
if (!collapsed && searchOnExpand) {
searchInput.current?.focus()
setSearchOnExpand(false)
}
}, [collapsed, searchOnExpand])
return (
<div className={clsx(css.root, collapsed && css.collapsed)}>
<div className={css.logoRow}>
{wide && (
<span className={clsx(css.brand, css.wide)}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
)}
<button
type="button"
className={css.iconButton}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
<button
type="button"
className={css.newSession}
aria-label="New session"
onClick={() => { onCreate() }}
>
<IconNewChatOutline16 size={14} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
</button>
<div className={css.sectionHeader}>
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
{wide && <GroupByMenu />}
<button
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Collapsed: the icon is the rail's search control. */}
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
tabIndex={collapsed ? 0 : -1}
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
>
<IconSearchOutline16 size={14} />
</button>
{wide && (
<input
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { setQuery(e.target.value) }}
/>
)}
{wide && query !== '' && (
<button
type="button"
className={clsx(css.clearButton, css.wide)}
aria-label="Clear search"
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
)}
</div>
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
both states while the tree itself is wide-only. */}
<div className={css.listArea}>
{wide && <SessionTree useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} query={query} />}
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
Settings
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
</div>
</div>
)

View File

@@ -1,49 +1,42 @@
/**
* Sidebar slot contract: the registrant-side props composition for the
* layout-owned `sidebar` slot. The own injected share is declared here (a
* share's type lives with whoever wires it); the owner share is referenced
* off ui-layout's slot declaration through OwnerOf, never re-stated. Single
* domain — this is the package's whole contract surface.
* share's type lives with whoever wires it); the runtime share — owner
* props {collapsed,width} plus the standard useSessions hook — is
* PropsRuntime<'sidebar'>, resolved off ui-layout's SlotMap declaration and
* never re-stated. Single domain — this is the package's whole contract
* surface.
*/
import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
// program that sees this contract, so OwnerOf<'sidebar'> resolves.
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarTreeState } from '../store.ts'
/** Cross-plugin actions bound in apply (layout / sessions services). */
export interface SidebarActions {
open(id: SessionId): void
create(cwd?: string): void
toggleSidebar(): void
}
/** Plugin-owned tree viewing-state actions (tree store mutators). */
export interface SidebarTreeActions {
toggleProject(key: string): void
toggleSession(id: SessionId): void
setQuery(query: string): void
}
/**
* Registrant-private injected share (arrives via the register inject
* factory). A type alias, not an interface: the alias carries an implicit
* index signature, so the factory's return crosses the registry's
* `Record<string, unknown>` boundary uncast.
* factory): plain cross-service callbacks only — tree data rides the
* standard useSessions hook and viewing state is component-local. A type
* alias, not an interface: the alias carries an implicit index signature,
* so the factory's return crosses the registry's `Record<string, unknown>`
* boundary uncast.
*/
export type SidebarRootInjected = {
useTree: SnapshotSelectorHook<SidebarTreeState>
/** Current session selector (row highlight); undefined selects nothing. */
useCurrent: () => SessionId | undefined
actions: SidebarActions
tree: SidebarTreeActions
/** Open (switch to) a session. */
onOpen: (id: SessionId) => void
/**
* Create a session and open it; cwd targets a project group (the
* sidebar's three creation entries all land in the new session).
*/
onCreate: (cwd?: string) => void
/** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */
onToggleSidebar: () => void
}
/**
* Full component props: owner share referenced from ui-layout's declaration
* plus the own injected share. Root scope has no standard injection
* (useSession is session-scope only), so no standard term appears.
* Full component props: the framework runtime share (owner {collapsed,width}
* + standard useSessions) plus the own injected share. No children are
* declared and no store is registered, so no PropsRenderSlots/PropsStore
* term appears.
*/
export type SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected
export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected

View File

@@ -1,71 +1,41 @@
/**
* Sidebar plugin, browser half: SidebarRoot registered into the layout-owned
* sidebar slot; tree derivation materialized in a plugin-owned snapshot
* store (pure consumer — no ctx service). Contract: api-contracts v3
* section 6; props composition in contract/slots.ts.
* sidebar slot. Pure consumer — the session list arrives through the
* standard useSessions prop, tree rows derive in the component, and the
* inject surface is plain cross-service callbacks closed over the plugin's
* own ctx (slot design sections 5 and 6); props composition in
* contract/slots.ts. Export discipline: packages/client/AGENTS.md.
*/
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { createSidebarTreeStore } from './store.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
export {
deriveRows, formatRelativeTime, projectLabel,
UNGROUPED_KEY, UNGROUPED_LABEL,
type ProjectRow, type SessionRow, type SidebarRow, type TreeView,
} from './tree.ts'
export {
createSidebarTreeStore,
type GroupBy, type SidebarTreeState, type SidebarTreeStore,
} from './store.ts'
export { ProjectRowItem, SessionRowItem } from './Rows.tsx'
export { SidebarRoot } from './SidebarRoot.tsx'
export type {
SidebarActions, SidebarRootComponentProps, SidebarRootInjected, SidebarTreeActions,
} from './contract/slots.ts'
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots', 'layout', 'sessions']
/**
* Client plugin body: build the tree store and register SidebarRoot into the
* sidebar slot with the inject surface bound off the root binding's ctx.
* Client plugin body: register SidebarRoot into the sidebar slot. The inject
* factory returns service callbacks only (no hooks, no store lines) — all
* data reads ride the framework's standard useSessions delivery.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.sessions
ctx.effect(() => {
const tree = createSidebarTreeStore(sessions)
// Called once per registration (root slots cache per entry); services are
// bound off the binding ctx per the contract's inject-surface wording.
const injectProps = (b: RootBinding<ClientContext>): SidebarRootInjected => {
const { sessions: boundSessions, layout } = b.ctx
return {
useTree: tree.store.useSelector,
useCurrent: () => layout.current.useSelector(s => s.sessionId),
actions: {
open: (id) => { layout.open(id) },
create: (cwd) => {
// Create-then-open: the sidebar's three creation entries all land
// in the new session (empty-state first-send stays with ui-conversation).
void boundSessions.create(cwd === undefined ? {} : { cwd })
.then((id: SessionId) => { layout.open(id) })
},
toggleSidebar: () => { layout.toggleSidebar() },
},
tree: {
toggleProject: (key) => { tree.toggleProject(key) },
toggleSession: (id) => { tree.toggleSession(id) },
setQuery: (query) => { tree.setQuery(query) },
},
}
}
const disposeRegistration = ctx.slots.register('sidebar', SidebarRoot, { inject: injectProps })
return () => {
disposeRegistration()
tree.dispose()
}
}, 'ui-sidebar: tree store + slot registration')
const injectProps = (): SidebarRootInjected => ({
// Selection lives with the runtime sessions service (current rides the
// list snapshot); layout keeps only panel geometry.
onOpen: (id) => { ctx.sessions.open(id) },
onCreate: (cwd) => {
// Create-then-open: the sidebar's three creation entries all land
// in the new session (empty-state first-send stays with ui-conversation).
void ctx.sessions.create(cwd === undefined ? {} : { cwd })
.then((id: SessionId) => { ctx.sessions.open(id) })
},
onToggleSidebar: () => { ctx.layout.toggleSidebar() },
})
ctx.effect(
() => ctx.slots.register({ name: 'sidebar', inject: injectProps }, SidebarRoot),
'ui-sidebar: slot registration',
)
}

View File

@@ -1,94 +0,0 @@
/**
* Sidebar tree store: plugin-owned snapshot store materializing the derived
* row list. Subscribes to sessions.list and re-derives on list changes and
* on viewing-state actions (expansion, search, group-by) — components
* subscribe to `rows` and never derive in render. Contract: api-contracts
* v3 section 6.
*/
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { deriveRows, type SidebarRow } from './tree.ts'
/** Grouping strategy. Only by-workspace is designed (figma); the menu shows the rest disabled. */
export type GroupBy = 'workspace'
/** Sidebar tree state: materialized rows plus the viewing state that shaped them. */
export interface SidebarTreeState {
rows: SidebarRow[]
/** Expanded project group keys (cwd or the ungrouped key). */
expandedProjects: string[]
/** Expanded session ids (subtree unfold). */
expandedSessions: string[]
query: string
groupBy: GroupBy
}
/** Store handle: snapshot store plus mutation actions and the list unsubscribe. */
export interface SidebarTreeStore {
readonly store: SnapshotStore<SidebarTreeState>
toggleProject(key: string): void
toggleSession(id: SessionId): void
setQuery(query: string): void
setGroupBy(groupBy: GroupBy): void
dispose(): void
}
/**
* Create the sidebar tree store bound to a sessions service.
* @param sessions - root sessions service (only the list store is consumed).
* @returns store handle; call dispose on plugin teardown.
*/
export function createSidebarTreeStore(sessions: Pick<SessionsService, 'list'>): SidebarTreeStore {
const store = createSnapshotStore<SidebarTreeState>({
rows: [],
expandedProjects: [],
expandedSessions: [],
query: '',
groupBy: 'workspace',
})
const rederive = (draft: SidebarTreeState): void => {
draft.rows = deriveRows(sessions.list.getSnapshot(), {
expandedProjects: new Set(draft.expandedProjects),
expandedSessions: new Set(draft.expandedSessions),
query: draft.query,
})
}
store.update(rederive)
const unsubscribe = sessions.list.subscribe(() => { store.update(rederive) })
const toggle = (list: string[], key: string): void => {
const at = list.indexOf(key)
if (at >= 0) list.splice(at, 1)
else list.push(key)
}
return {
store,
toggleProject(key) {
store.update((draft) => {
toggle(draft.expandedProjects, key)
rederive(draft)
})
},
toggleSession(id) {
store.update((draft) => {
toggle(draft.expandedSessions, id)
rederive(draft)
})
},
setQuery(query) {
store.update((draft) => {
draft.query = query
rederive(draft)
})
},
setGroupBy(groupBy) {
store.update((draft) => {
draft.groupBy = groupBy
rederive(draft)
})
},
dispose: unsubscribe,
}
}

View File

@@ -2,8 +2,9 @@
* Pure sidebar tree derivation: session list snapshot -> flat render rows.
* Groups sessions by project directory (cwd), builds the per-group session
* tree from parentId links, sorts by recency, and applies search filtering
* with forced ancestor visibility. Components subscribe to the materialized
* rows and never derive in render. Contract: api-contracts v3 section 6.
* with forced ancestor visibility. Derived data is a pure function (slot
* design section 6): the component feeds the useSessions snapshot plus its
* local viewing state through useMemo — no materializing store.
*/
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -43,10 +44,10 @@ export interface SessionRow {
/** One flat sidebar list row. */
export type SidebarRow = ProjectRow | SessionRow
/** Viewing state consumed by the derivation. */
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
export interface TreeView {
expandedProjects: ReadonlySet<string>
expandedSessions: ReadonlySet<string>
expandedProjects: readonly string[]
expandedSessions: readonly string[]
query: string
}
@@ -217,17 +218,19 @@ function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarR
* without a display-title or label hit are dropped, and a label-only hit keeps the
* bare project row.
* @param list - sessions list snapshot.
* @param view - expansion sets and search query.
* @param view - local expansion arrays and search query.
* @returns rows in render order.
*/
export function deriveRows(list: SessionListState, view: TreeView): SidebarRow[] {
const q = view.query.trim().toLowerCase()
const expandedProjects = new Set(view.expandedProjects)
const expandedSessions = new Set(view.expandedSessions)
const rows: SidebarRow[] = []
for (const g of groupByCwd(list)) {
if (q === '') {
const expanded = view.expandedProjects.has(g.key)
const expanded = expandedProjects.has(g.key)
rows.push({ type: 'project', key: g.key, cwd: g.cwd, label: g.label, sessionCount: g.summaries.size, expanded })
if (expanded) flattenVisible(g, view.expandedSessions, rows)
if (expanded) flattenVisible(g, expandedSessions, rows)
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue

View File

@@ -15,10 +15,10 @@ export const name = 'client-ui-sidebar-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin deriving its tree store from
* sessions.list — it emits no cordis events and owns no cross-plugin mutable
* state; derivation and interaction behavior are asserted directly by this
* package's tree/store/component specs.
* No runtime invariant: a pure-consumer plugin deriving its rows in-component
* from the standard useSessions delivery — it emits no cordis events and owns
* no cross-plugin mutable state; derivation and interaction behavior are
* asserted directly by this package's tree/component specs.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,55 +1,53 @@
// @vitest-environment jsdom
/**
* apply wiring on a real cordis Context + SlotsService: tree store built and
* subscribed, SidebarRoot registered into the layout-owned sidebar slot with
* the inject surface bound off the root binding ctx, effect teardown
* unregisters and drops the list subscription. Behavior-level assertions
* only — the inject factory's cast shape is due to change with the slot
* type-chain redesign.
* apply wiring on a real cordis Context + SlotsService (terminal register
* form): SidebarRoot registered into the layout-declared sidebar slot, the
* thin inject surface (three plain service callbacks closed over the plugin
* ctx — no hooks, no store lines), load-order fail-loud, and fiber-teardown
* unregistration. Component behavior is covered props-direct in
* sidebar-root.spec.tsx; no renderer machinery here.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { scopedSlots, RootBindingProvider } from '@deepseek-ai/dsh-client-web-react'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
// Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
const sid = (s: string) => s as SessionId
afterEach(cleanup)
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const list = createSnapshotStore<SessionListState>({
ids: [sid('a')],
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
current: undefined,
})
const sessions = { list, create: vi.fn(async () => sid('minted')) }
const layout = {
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
open: vi.fn(),
toggleSidebar: vi.fn(),
}
const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() }
const layout = { toggleSidebar: vi.fn() }
ctx.provide('sessions', sessions)
ctx.provide('layout', layout)
const slots = ctx.get('slots') as SlotsService
slots.define('sidebar', { kind: 'single', scope: 'root' })
// Stand-in for ui-layout's root entry: the sidebar slot only exists while
// a live entry declares it in children (declaration account: design §2.2).
slots.register(
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
return { ctx, slots, sessions, layout }
}
function mountSlot(ctx: Context, slots: SlotsService) {
const surface = scopedSlots(slots.core, 'sidebar')
return render(
<RootBindingProvider value={{ ctx }}>
{surface.renderSlot('sidebar', {})}
</RootBindingProvider>,
)
/** The sidebar entry's injected share, read off the stored entry. */
function injectedOf(slots: SlotsService): SidebarRootInjected {
const entries = slots.entries('sidebar')
expect(entries).toHaveLength(1)
// The typed StoredEntry.inject is declaration-derived ((...args: never[])
// shape); the sidebar factory is parameterless, so the call is safe here.
const inject = entries[0]!.inject as (() => SidebarRootInjected) | undefined
return inject!()
}
describe('apply', () => {
@@ -58,101 +56,57 @@ describe('apply', () => {
})
it('fails loud when mounted without the inject declaration', async () => {
// ctx.sessions rides the cordis property proxy: reading it from a plugin
// ctx.slots rides the cordis property proxy: reading it from a plugin
// that never declared the dependency throws instead of yielding undefined.
// Await the fiber thenable itself, not a second .await() chain: the test
// invariant host wraps plugin() with an eager readiness promise, and only
// the thenable settles it (a parallel .await() leaves it unhandled).
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
})
it('registers SidebarRoot which renders from the live list', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('1 session')).toBeTruthy()
it('fails loud when no live entry has declared the sidebar slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', {})
ctx.provide('layout', {})
await expect(ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/slot "sidebar" is not declared/)
})
it('binds actions to layout/sessions off the root binding', async () => {
it('registers SidebarRoot with the thin three-callback inject surface', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(slots)
// The whole business face: three plain callbacks, no hooks, no store lines.
expect(Object.keys(injected).sort()).toEqual(['onCreate', 'onOpen', 'onToggleSidebar'])
})
it('routes the callbacks to the layout/sessions services', async () => {
const { ctx, slots, sessions, layout } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
const injected = injectedOf(slots)
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
injected.onToggleSidebar()
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('alpha')) })
expect(layout.open).toHaveBeenCalledWith('a')
injected.onOpen(sid('a'))
expect(sessions.open).toHaveBeenCalledWith('a')
act(() => { fireEvent.click(screen.getByText('New Session')) })
injected.onCreate()
expect(sessions.create).toHaveBeenCalledWith({})
// create-then-open lands after the create promise resolves.
await act(async () => { await Promise.resolve() })
expect(layout.open).toHaveBeenCalledWith('minted')
await Promise.resolve()
await Promise.resolve()
expect(sessions.open).toHaveBeenCalledWith('minted')
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
injected.onCreate('/proj')
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
})
it('throws from the inject factory when binding ctx lacks the services', async () => {
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const bare = new Context()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const surface = scopedSlots(slots.core, 'sidebar')
render(
<RootBindingProvider value={{ ctx: bare }}>
{surface.renderSlot('sidebar', {})}
</RootBindingProvider>,
)
// The slot error boundary absorbs the throw and logs it.
expect(document.querySelector('[data-slot-error="sidebar"]')).toBeTruthy()
} finally {
spy.mockRestore()
}
})
it('search input drives the plugin-owned tree store', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
act(() => {
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'zzz' } })
})
expect(screen.getByText('No matches')).toBeTruthy()
})
it('expansion toggles route through the injected tree actions', async () => {
const { ctx, slots, sessions } = await bench()
sessions.list.update((draft) => {
draft.ids.push(sid('kid'))
draft.byId[sid('kid')] = {
id: sid('kid'), title: 'child', displayTitle: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2,
}
})
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
act(() => { fireEvent.click(screen.getByText('proj')) })
expect(screen.getByText('alpha')).toBeTruthy()
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
expect(screen.getByText('child')).toBeTruthy()
})
it('teardown unregisters the slot and drops the list subscription', async () => {
const { ctx, slots, sessions } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('sidebar')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('sidebar')).toHaveLength(0)
// A post-teardown list change must not reach a disposed store.
expect(() => {
sessions.list.update((draft) => { draft.ids = [] })
}).not.toThrow()
})
})

View File

@@ -1,19 +1,26 @@
// @vitest-environment jsdom
/**
* SidebarRoot interaction spec on the real framework stack: real tree store
* (web-react SnapshotStore) feeding the component through the same selector
* hook the inject surface hands out. Covers expand/collapse, subtree unfold,
* search filtering, row activation, and the creation entries.
* SidebarRoot interaction spec, props-direct (slot-parity test doctrine:
* components are fed composed props, no assembly machinery). The standard
* useSessions hook is stubbed with a real web-react SnapshotStore selector;
* expansion/search live inside the component, so all viewing behavior is
* driven through the DOM. Covers expand/collapse, subtree unfold, search
* filtering, row activation, and the creation entries.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { act, useSyncExternalStore } from 'react'
// Engine home: runtime/client since the store migration; the engine carries
// no hook (runtime is React-free), so the spec binds the selector locally.
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSidebarTreeStore, SidebarRoot,
type SidebarActions, type SidebarTreeStore,
} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
/** Minimal selector hook over an engine store (production binding lives in the renderer). */
function hookOf<T>(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) {
return <S,>(sel: (s: T) => S, _eq?: (a: S, b: S) => boolean): S =>
sel(useSyncExternalStore(src.subscribe.bind(src), src.getSnapshot.bind(src)))
}
const sid = (s: string) => s as SessionId
@@ -43,29 +50,36 @@ function summary(init: SummaryInit): SessionSummary {
function listStateOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map((s) => s.id), byId }
return { ids: summaries.map((s) => s.id), byId, current: undefined }
}
afterEach(cleanup)
function mount(...summaries: SessionSummary[]) {
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const tree: SidebarTreeStore = createSidebarTreeStore({ list })
const current = createSnapshotStore<{ id: SessionId | undefined }>({ id: undefined })
const actions: SidebarActions = {
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
create: vi.fn(),
toggleSidebar: vi.fn(),
}
const utils = render(
// Real engine store as the useSessions stub: same uSES selector shape the
// framework delivers, so list updates re-render exactly like production.
const sessions = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) })
const onCreate = vi.fn()
// The owner decides collapsed in production (AppFrame maps the preference);
// the harness mirrors that loop so the toggle drives a re-render.
let collapsed = false
const view = (width: number) => (
<SidebarRoot
useTree={tree.store.useSelector}
useCurrent={() => current.useSelector((s) => s.id)}
actions={actions}
tree={tree}
/>,
collapsed={collapsed}
width={width}
useSessions={hookOf(sessions)}
onOpen={onOpen}
onCreate={onCreate}
onToggleSidebar={onToggleSidebar}
/>
)
return { list, tree, current, actions, ...utils }
const onToggleSidebar = vi.fn(() => {
collapsed = !collapsed
utils.rerender(view(collapsed ? 56 : 300))
})
const utils = render(view(300))
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
}
const projectData = () => [
@@ -74,6 +88,9 @@ const projectData = () => [
summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
]
/** Flush the store's microtask-batched notification into React. */
const flush = async () => { await act(async () => { await Promise.resolve() }) }
describe('SidebarRoot', () => {
it('renders chrome and collapsed project rows', () => {
mount(...projectData())
@@ -96,11 +113,13 @@ describe('SidebarRoot', () => {
expect(screen.queryByText('forked child')).toBeNull()
})
it('opens a session on row click and marks it selected', () => {
const { actions } = mount(...projectData())
it('opens a session on row click and marks it selected', async () => {
const { onOpen } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('root work')) })
expect(actions.open).toHaveBeenCalledWith('root')
expect(onOpen).toHaveBeenCalledWith('root')
// The mock routed the open into sessions.current — highlight follows.
await flush()
expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
})
@@ -130,20 +149,91 @@ describe('SidebarRoot', () => {
})
it('routes the three creation entries with the right cwd', () => {
const { actions } = mount(...projectData())
const { onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('New Session')) })
expect(actions.create).toHaveBeenLastCalledWith()
expect(onCreate).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
expect(actions.create).toHaveBeenLastCalledWith()
expect(onCreate).toHaveBeenLastCalledWith()
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
expect(actions.create).toHaveBeenLastCalledWith('/proj')
expect(onCreate).toHaveBeenLastCalledWith('/proj')
})
it('collapse button and group-by menu behave', () => {
const { actions } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
it('collapse fades the wide content out, then the rail keeps the four controls', () => {
vi.useFakeTimers()
try {
const { onToggleSidebar, onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledOnce()
// Fade window: the wide chrome is still mounted while it fades.
expect(screen.getByText('HARNESS')).toBeTruthy()
expect(screen.getByRole('tree')).toBeTruthy()
// Settle: wide content unmounts, the rail controls remain.
act(() => { vi.advanceTimersByTime(300) })
expect(screen.queryByText('HARNESS')).toBeNull()
expect(screen.queryByText('New Session')).toBeNull()
expect(screen.queryByRole('tree')).toBeNull()
// Rail order mirrors the expanded rows: expand, new session, new workspace, search.
const rail = ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
.map((label) => screen.getByLabelText(label))
for (let i = 1; i < rail.length; i++) {
expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
}
// Rail creation entries route like their expanded counterparts.
act(() => { fireEvent.click(screen.getByLabelText('New session')) })
expect(onCreate).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
expect(screen.getByText('New Session')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('rail search expands the sidebar and focuses the search box', () => {
vi.useFakeTimers()
try {
const { onToggleSidebar } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { vi.advanceTimersByTime(300) })
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
const input = screen.getByPlaceholderText('Search name, keywords...')
expect(document.activeElement).toBe(input)
} finally {
vi.useRealTimers()
}
})
it('expanded search focuses without toggling the sidebar', () => {
const { onToggleSidebar } = mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(document.activeElement).toBe(input)
expect(onToggleSidebar).not.toHaveBeenCalled()
})
it('the search query survives a collapse/expand round trip', () => {
vi.useFakeTimers()
try {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { vi.advanceTimersByTime(300) })
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement
expect(restored.value).toBe('forked')
expect(screen.getByText('forked child')).toBeTruthy()
expect(screen.queryByText('elsewhere')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('group-by menu behaves', () => {
mount(...projectData())
expect(screen.queryByText('Update')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
expect(screen.getByText('Update')).toBeTruthy()
@@ -158,28 +248,27 @@ describe('SidebarRoot', () => {
})
it('re-renders when the sessions list gains a session', async () => {
const { list } = mount(...projectData())
const { sessions } = mount(...projectData())
act(() => {
list.update((draft) => {
sessions.update((draft) => {
draft.ids.push(sid('fresh'))
draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
})
})
// Store notifications are microtask-batched.
await act(async () => { await Promise.resolve() })
await flush()
expect(screen.getByText('fresh')).toBeTruthy()
})
it('row "More" anchors swallow the click without opening or toggling', () => {
const { actions, tree } = mount(...projectData())
const { onOpen } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
const before = tree.store.getSnapshot().expandedProjects.length
// Project-row anchor: must not collapse the project.
// Project-row anchor: must not collapse the project (rows stay visible).
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
expect(tree.store.getSnapshot().expandedProjects).toHaveLength(before)
expect(screen.getByText('root work')).toBeTruthy()
// Session-row anchor: must not open the session.
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
expect(actions.open).not.toHaveBeenCalled()
expect(onOpen).not.toHaveBeenCalled()
})
it('shows the running state dot only for running sessions', () => {

View File

@@ -1,112 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { createSidebarTreeStore } from '@deepseek-ai/dsh-client-ui-sidebar/client'
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
interface SummaryInit {
id: string
title?: string
cwd?: string
parentId?: string
running?: boolean
updatedAt?: number
}
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
title: init.title ?? init.id,
displayTitle: init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
}
function listStateOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map(s => s.id), byId }
}
function setup(...summaries: SessionSummary[]) {
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const tree = createSidebarTreeStore({ list })
return { list, tree }
}
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
describe('createSidebarTreeStore', () => {
it('materializes rows from the initial list snapshot', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
expect(tree.store.getSnapshot().rows).toEqual([
expect.objectContaining({ type: 'project', key: '/p', sessionCount: 1 }),
])
})
it('re-derives when the sessions list changes', async () => {
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
list.update((draft) => {
draft.ids.push(sid('b'))
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q', updatedAt: 99 })
})
// Snapshot-store notifications are microtask-batched.
await flushMicrotasks()
expect(tree.store.getSnapshot().rows.map(r => r.type === 'project' && r.key)).toEqual(['/q', '/p'])
})
it('toggleProject expands and collapses synchronously', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(2)
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
it('toggleSession unfolds a subtree', () => {
const { tree } = setup(
summary({ id: 'root', cwd: '/p', updatedAt: 2 }),
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 1 }),
)
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(2)
tree.toggleSession(sid('root'))
expect(tree.store.getSnapshot().rows).toHaveLength(3)
})
it('setQuery switches into search mode and back', () => {
const { tree } = setup(
summary({ id: 'a', title: 'needle', cwd: '/p' }),
summary({ id: 'b', title: 'other', cwd: '/q' }),
)
tree.setQuery('needle')
const rows = tree.store.getSnapshot().rows
expect(rows.map(r => r.type)).toEqual(['project', 'session'])
tree.setQuery('')
expect(tree.store.getSnapshot().rows.every(r => r.type === 'project')).toBe(true)
})
it('setGroupBy records the strategy and re-derives', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.setGroupBy('workspace')
expect(tree.store.getSnapshot().groupBy).toBe('workspace')
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
it('dispose stops re-derivation on list changes', async () => {
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.dispose()
list.update((draft) => {
draft.ids.push(sid('b'))
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q' })
})
await flushMicrotasks()
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
})

View File

@@ -3,7 +3,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
import {
deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL,
type SessionRow, type TreeView,
} from '@deepseek-ai/dsh-client-ui-sidebar/client'
} from '../src/client/tree.ts'
const sid = (s: string) => s as SessionId
@@ -34,12 +34,12 @@ function summary(init: SummaryInit): SessionSummary {
function listOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map(s => s.id), byId }
return { ids: summaries.map(s => s.id), byId, current: undefined }
}
const view = (partial: Partial<TreeView> = {}): TreeView => ({
expandedProjects: partial.expandedProjects ?? new Set(),
expandedSessions: partial.expandedSessions ?? new Set(),
expandedProjects: partial.expandedProjects ?? [],
expandedSessions: partial.expandedSessions ?? [],
query: partial.query ?? '',
})
@@ -96,7 +96,7 @@ describe('deriveRows grouping', () => {
summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
)
expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
const rows = deriveRows(list, view({ expandedProjects: new Set(['/p']) }))
const rows = deriveRows(list, view({ expandedProjects: ['/p'] }))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
@@ -114,8 +114,8 @@ describe('deriveRows session tree', () => {
it('nests children under expanded parents with increasing depth', () => {
const rows = deriveRows(treeList, view({
expandedProjects: new Set(['/p']),
expandedSessions: new Set(['root', 'kid']),
expandedProjects: ['/p'],
expandedSessions: ['root', 'kid'],
}))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
@@ -126,7 +126,7 @@ describe('deriveRows session tree', () => {
})
it('collapses subtrees at unexpanded sessions', () => {
const rows = deriveRows(treeList, view({ expandedProjects: new Set(['/p']) }))
const rows = deriveRows(treeList, view({ expandedProjects: ['/p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['other', 'root'])
})
@@ -135,7 +135,7 @@ describe('deriveRows session tree', () => {
const rows = deriveRows(listOf(
summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
), view({ expandedProjects: new Set(['/a', '/b']) }))
), view({ expandedProjects: ['/a', '/b'] }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/a' }),
expect.objectContaining({ id: 'p1', depth: 0 }),
@@ -149,7 +149,7 @@ describe('deriveRows session tree', () => {
summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }),
summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }),
summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }),
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['x', 'y', 'self']) }))
), view({ expandedProjects: ['/p'], expandedSessions: ['x', 'y', 'self'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toContain('self')
expect(ids).toContain('x')
@@ -162,7 +162,7 @@ describe('deriveRows session tree', () => {
summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
), view({ expandedProjects: new Set(['/p']) }))
), view({ expandedProjects: ['/p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['a', 'b', 'c'])
})
@@ -172,7 +172,7 @@ describe('deriveRows session tree', () => {
summary({ id: 'p', cwd: '/p', updatedAt: 9 }),
summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }),
summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }),
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['p']) }))
), view({ expandedProjects: ['/p'], expandedSessions: ['p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['p', 'new', 'old'])
})
@@ -180,7 +180,7 @@ describe('deriveRows session tree', () => {
it('carries the running flag onto rows', () => {
const rows = deriveRows(
listOf(summary({ id: 'a', cwd: '/p', running: true })),
view({ expandedProjects: new Set(['/p']) }))
view({ expandedProjects: ['/p'] }))
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
})
})

View File

@@ -1,15 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"