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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user