feat(web): add workspace-aware session flow

This commit is contained in:
imccyu
2026-07-25 16:04:48 +08:00
parent 755e2a8c51
commit 9eb9c70a8a
170 changed files with 7573 additions and 3006 deletions

View File

@@ -1,10 +1,10 @@
# @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. Top-level New Session / New Workspace clear the selection onto `conversation.empty`; per-project "+" still create-then-opens. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.
New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar.
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`.
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` child slot, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state.
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).

View File

@@ -104,6 +104,18 @@
line-height: 20px;
}
.renameInput {
min-width: 0;
font-size: 14px;
line-height: 20px;
padding: 0 2px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
background: var(--dsw-alias-button-elevated-fill);
color: inherit;
outline: none;
}
.sessionRow .title {
flex: 1;
}

View File

@@ -5,10 +5,10 @@
*/
import clsx from 'clsx'
import {
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTriangleRightFill14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ProjectRow, SessionRow } from './tree.ts'
import type { GroupNode, SessionNode } from './tree.ts'
import { formatRelativeTime } from './tree.ts'
import css from './Rows.module.css'
@@ -16,20 +16,21 @@ import css from './Rows.module.css'
const INDENT_STEP = 16
/**
* Project (workspace) row: 54px, folder + title + session count; hover
* reveals the chevron and the more/create buttons.
* @param props.row - derived project row.
* @param props.active - group contains the selected session (blue open folder).
* Project (workspace) header row: 54px, folder + title + session count;
* hover reveals the chevron and create button. `containsCurrent` arrives on
* the node (derivation fact, no renderer scan).
* @param props.group - derived group node.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - create a session inside this group.
* @param props.onCreate - start a frontend Session inside this Workspace.
* @returns the row element.
*/
export function ProjectRowItem({ row, active, onToggle, onCreate }: {
row: ProjectRow
active: boolean
export function ProjectRowItem({ group, onToggle, onCreate }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
}) {
const row = group
const active = group.expanded && group.containsCurrent
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
return (
<div className={css.projectRow} role="treeitem" aria-expanded={row.expanded} onClick={onToggle}>
@@ -44,14 +45,10 @@ export function ProjectRowItem({ row, active, onToggle, onCreate }: {
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
{/* Row menu contents are not designed yet (figma draft notes); the button is the reserved anchor. */}
<button type="button" className={css.iconButton} aria-label="More" onClick={(e) => { e.stopPropagation() }}>
<IconEllipsisOutline16 />
</button>
<button
type="button"
className={css.iconButton}
aria-label="New session here"
aria-label={`New session in ${row.label}`}
onClick={(e) => { e.stopPropagation(); onCreate() }}
>
<IconPlusOutline16 />
@@ -62,33 +59,53 @@ export function ProjectRowItem({ row, active, onToggle, onCreate }: {
}
/**
* Session row: 34px, indent by depth, expand twist when it has children,
* running state dot, relative time swapping to the more button on hover.
* @param props.row - derived session row.
* @param props.selected - row is the current session.
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open this session.
* @param props.onToggle - unfold/fold the subtree.
* @returns the row element.
* The selected "New session" row for a frontend Session Intent targeted to a
* real Workspace. The row disappears when the Intent is replaced or connects.
* @returns the placeholder row element.
*/
export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
row: SessionRow
selected: boolean
export function IntentRowItem() {
return (
<div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}>
<span className={css.slot} />
<span className={css.slot} />
<span className={css.title}>New session</span>
</div>
)
}
/**
* One session subtree: the node's own 34px row (indent by depth, expand
* twist when it has children, running dot, relative time) plus its visible
* children, recursively — the component tree mirrors the derived tree.
* @param props.node - derived session node.
* @param props.depth - 0 = directly under the group header.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onToggle - unfold/fold a subtree by id.
* @returns the node's row followed by its children.
*/
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle }: {
node: SessionNode
depth: number
currentId: string | undefined
now: number
onOpen: () => void
onToggle: () => void
onOpen: (id: SessionNode['id']) => void
onToggle: (id: SessionNode['id']) => void
}) {
const row = node
const selected = node.id === currentId
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
// the title): both slots are always reserved so titles align whether or not
// the twist/dot is lit. Extra depth rides the left padding.
return (
const ownRow = (
<div
className={clsx(css.sessionRow, selected && css.selected)}
role="treeitem"
aria-selected={selected}
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
style={{ paddingLeft: 8 + row.depth * INDENT_STEP }}
onClick={onOpen}
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
onClick={() => { onOpen(node.id) }}
>
{row.hasChildren
? (
@@ -96,7 +113,7 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle() }}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
@@ -105,11 +122,22 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
<span className={css.rowActions}>
<button type="button" className={css.iconButton} aria-label="More" onClick={(e) => { e.stopPropagation() }}>
<IconEllipsisOutline16 />
</button>
</span>
</div>
)
return (
<>
{ownRow}
{node.children.map(child => (
<SessionNodeItem
key={child.id}
node={child}
depth={depth + 1}
currentId={currentId}
now={now}
onOpen={onOpen}
onToggle={onToggle}
/>
))}
</>
)
}

View File

@@ -339,24 +339,33 @@
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. */
/* Tree list: the only scrolling region. Block, not a flex column: as flex
items the 54/34 rows would shrink under content overflow (scrollHeight
collapses onto clientHeight and wheel scrolling dies); block children keep
their design heights and the 4px rhythm rides margins instead of gap. */
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: 12px;
}
/* One workspace section: header row + expanded session run. Rows inside
keep the former flat-list 4px gap as sibling margins; the inter-group
breathing room (figma 133:7661 batch separator, 20px after an expanded
run) rides the NEXT section's top margin so the last group adds none. */
.groupSection > * + * {
margin-top: 4px;
}
.groupSection + .groupSection {
margin-top: 4px;
}
.groupSection:has([aria-expanded='true']) + .groupSection {
margin-top: 20px;
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);

View File

@@ -7,7 +7,7 @@
* (one icon each, same top-down order) fading in as the slide ends. Rail
* search expands and focuses the search box.
*/
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
BrandWordmark, FishLogo,
@@ -15,9 +15,10 @@ import {
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
Menu, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootComponentProps } from './contract/slots.ts'
import { deriveRows } from './tree.ts'
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
import { deriveGroups, UNGROUPED_KEY } from './tree.ts'
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx'
import css from './SidebarRoot.module.css'
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
@@ -27,7 +28,7 @@ const COLLAPSE_SETTLE_MS = 150
const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [
{ id: 'workspace', label: 'WorkSpace' },
{ id: 'workspace', label: 'Workspace' },
// Only workspace grouping is implemented.
{ id: 'update', label: 'Update', disabled: true },
{ id: 'status', label: 'Status', disabled: true },
@@ -63,62 +64,74 @@ function GroupByMenu() {
)
}
type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
type SessionTreeProps = Pick<
SidebarRootComponentProps,
'useSessions' | 'startSession' | 'open'
> & {
workspaces: readonly WorkspaceView[]
/** 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) {
function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) {
const list = useSessions((s) => s)
// Selection belongs to the sessions snapshot, not layout state.
const current = useSessions((s) => s.current)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
const rows = useMemo(
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
[list, expandedProjects, expandedSessions, query],
// Re-expand when publication moves the selected intent into a real Workspace.
const intent = list.intent
const intentWorkspaceId = intent?.target.kind === 'workspace'
? intent.target.workspaceId
: undefined
const currentGroup = current === undefined
? undefined
: intent?.sessionId === current
? intentWorkspaceId
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
useEffect(() => {
if (current === undefined || currentGroup === undefined) return
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
[list, workspaces, expandedProjects, expandedSessions, query],
)
const now = Date.now()
// Presentational lookup (not tree derivation): the group holding the
// selected session gets the active folder; only expanded groups can show it.
let activeGroup: string | undefined
if (current !== undefined) {
for (const row of rows) {
if (row.type === 'session' && row.id === current) { activeGroup = row.groupKey; break }
}
}
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
{rows.length === 0 && (
{groups.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
)}
{rows.map((row, i) => row.type === 'project'
? (
<Fragment key={`p:${row.key}`}>
{/* Batch separator: a project row closing an expanded session run (figma 133:7661). */}
{i > 0 && rows[i - 1]!.type === 'session' && <span className={css.batchGap} />}
<ProjectRowItem
row={row}
active={row.key === activeGroup}
onToggle={() => { setExpandedProjects((l) => toggled(l, row.key)) }}
onCreate={() => { onCreate(row.cwd) }}
/>
</Fragment>
)
: (
<SessionRowItem
key={row.id}
row={row}
selected={row.id === current}
{groups.map(group => (
// Group section: header row + expanded session subtree. The
// inter-group breathing room (former flat-list batch separator)
// is the section's own margin (SidebarRoot.module.css).
<div key={group.key} className={css.groupSection}>
<ProjectRowItem
group={group}
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
/>
{group.intentHere && <IntentRowItem />}
{group.sessions.map(node => (
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={current}
now={now}
onOpen={() => { onOpen(row.id) }}
onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }}
onOpen={open}
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
/>
))}
</div>
))}
</div>
<span className={css.fade} />
</div>
@@ -130,11 +143,27 @@ function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps)
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
export function SidebarRoot({
collapsed,
width,
useSessions,
useWorkspaces,
startSession,
open,
toggleSidebar,
renderSlot,
}: SidebarRootComponentProps) {
const workspaces = useWorkspaces(state => state.items)
// 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)
// Section-header + opens the workspace picker (same popover in wide and
// rail states; the hole sits beside the button and opens rightward).
const [wsPickerOpen, setWsPickerOpen] = useState(false)
// Placement anchor for the picker popover: the slot span renders elsewhere
// in the DOM, so the picker positions off this button's rect.
const wsPlusRef = useRef<HTMLButtonElement>(null)
// Wide content stays mounted while the collapse animates (fading via
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
@@ -188,7 +217,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
type="button"
className={clsx(css.iconButton, css.toggle)}
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
onClick={() => { onToggleSidebar() }}
onClick={() => { toggleSidebar() }}
>
{!wide && <FishLogo className={css.railFish} size={24} />}
{/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
@@ -202,7 +231,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
type="button"
className={css.newSession}
aria-label="New session"
onClick={() => { onCreate() }}
onClick={() => { startSession() }}
>
<IconNewChatOutline16 size={wide ? 14 : 18} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
@@ -210,18 +239,29 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
</Tooltip>
<div className={css.sectionHeader}>
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
{wide && <span className={clsx(css.sectionLabel, css.wide)}>Workspaces</span>}
{wide && <GroupByMenu />}
<Tooltip label="New Workspace" disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
aria-label="Create workspace"
onClick={() => { setWsPickerOpen(v => !v) }}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
{/* Picker hole beside the + (same site in wide and rail states). */}
{renderSlot('sidebar.workspace', {
open: wsPickerOpen,
anchorRef: wsPlusRef,
onPick: (workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
},
onClose: () => { setWsPickerOpen(false) },
})}
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
@@ -233,7 +273,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
className={css.searchButton}
aria-label="Search sessions"
tabIndex={collapsed ? 0 : -1}
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }}
>
<IconSearchOutline16 size={wide ? 14 : 18} />
</button>
@@ -263,7 +303,15 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
{/* 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} />}
{wide && (
<SessionTree
useSessions={useSessions}
workspaces={workspaces}
startSession={startSession}
open={open}
query={query}
/>
)}
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">

View File

@@ -1,42 +1,69 @@
/**
* 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 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.
* layout-owned `sidebar` slot and the Workspace picker hole declared here.
* The runtime share combines layout-owned page state and actions with the
* global useSessions and useWorkspaces hooks; the injected share adds the
* runtime navigation actions and sidebar toggle.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { RefObject } from 'react'
import type { PropsRenderSlots, 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 PropsRuntime<'sidebar'> resolves.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
/**
* Registrant-private injected share (arrives via the register inject
* 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 = {
/** Open (switch to) a session. */
onOpen: (id: SessionId) => void
/**
* New-session affordance: no cwd clears selection onto the empty-state
* launch; a cwd create-then-opens a session in that project group.
*/
onCreate: (cwd?: string) => void
/** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */
onToggleSidebar: () => void
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* The workspace picker hole in the sidebar section header (anchored at
* the + button). Declared by this package's 'sidebar' entry (declaring
* is claiming); ui-workspace registers the picker.
*/
'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps }
}
}
/**
* 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.
* Owner share of the sidebar workspace hole: popover geometry plus the
* sidebar's pick semantics. The picked Host Workspace is already real; the
* callback starts a frontend Session Intent targeted to it.
*/
export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected
export interface SidebarWorkspaceOwnerProps {
/** Popover visibility (+ button toggle state, host-local). */
open: boolean
/**
* The + button element — the popover's placement anchor. The picker's
* slot span renders elsewhere in the DOM, so without this the menu
* positions off the zero-size placement span (order-dependent). Optional
* only until the host passes it; absent falls back to in-place placement.
*/
anchorRef?: RefObject<HTMLElement>
/** Start a frontend Session in a selected or newly created real Workspace. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
}
/**
* Registrant-private injected share (arrives via the register inject
* factory). Host Workspace and Session data use the global framework hooks;
* navigation and panel actions are plain callbacks, and viewing state remains
* component-local. A type alias supplies the implicit index signature required
* by the registry.
*/
export type SidebarRootInjected = {
/** Start or replace the current frontend Session Intent. */
startSession: (workspaceId?: WorkspaceId, prompt?: string) => void
/** Open a real Session. */
open: (sessionId: SessionId) => void
/** Toggle the sidebar column through the layout service. */
toggleSidebar: () => void
}
/**
* Full component props: layout owner state/actions plus global useSessions
* and useWorkspaces, the declared Workspace picker render share, and this
* package's injected callback. No store is registered.
*/
export type SidebarRootComponentProps =
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected

View File

@@ -1,36 +1,30 @@
/** Registers the sidebar UI into the layout-owned slot. */
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
/** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions']
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
/** Registers the sidebar component and its service callbacks.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
const injectProps = (): SidebarRootInjected => ({
// Selection belongs to the sessions service; layout owns only panel geometry.
onOpen: (id) => { ctx.sessions.open(id) },
onCreate: (cwd) => {
// Top-level New Session / New Workspace: clear selection so AppFrame
// shows conversation.empty (EmptyState + shared InputBar). Per-project
// "+" still create-then-opens into that cwd until workspace seeding
// reaches the empty-state picker.
if (cwd === undefined) {
ctx.sessions.clear()
return
}
void ctx.sessions.create({ cwd })
.then((id: SessionId) => { ctx.sessions.open(id) })
},
onToggleSidebar: () => { ctx.layout.toggleSidebar() },
startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
toggleSidebar: () => { ctx.layout.toggleSidebar() },
})
ctx.effect(
() => ctx.slots.register({ name: 'sidebar', inject: injectProps }, SidebarRoot),
() => ctx.slots.register({
name: 'sidebar',
// SidebarRoot owns this picker site; ui-workspace registers the shared
// picker that selects a Host Workspace for a frontend Session Intent.
children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } },
inject: injectProps,
}, SidebarRoot),
'ui-sidebar: slot registration',
)
}

View File

@@ -1,41 +1,46 @@
/** Pure derivation of flat sidebar rows from sessions and local view state. */
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
/**
* Derives the sidebar tree from Host Workspace order and membership.
* Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render.
*/
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for sessions without a project directory. */
/** Group key for Sessions outside every Workspace. */
export const UNGROUPED_KEY = ''
/** Display label for the ungrouped project row. */
/** Display label for the ungrouped bucket row. */
export const UNGROUPED_LABEL = 'Ungrouped'
/** Project (workspace) row: 54px, two lines (label + session count). */
export interface ProjectRow {
type: 'project'
/** Group key: the cwd, or {@link UNGROUPED_KEY}. */
key: string
cwd: string | undefined
label: string
/** Total sessions in the group, including hidden ones. */
sessionCount: number
expanded: boolean
}
/** Session row: 34px single line; depth drives the 22px indent steps. */
export interface SessionRow {
type: 'session'
/** One session node of a group's visible tree (34px row; children render indented one step). */
export interface SessionNode {
id: SessionId
/** Owning project group key (selection -> active-folder lookup). */
groupKey: string
title: string
/** 0 = directly under the project row. */
depth: number
/** Visible children, already expansion/search-filtered (empty when folded). */
children: readonly SessionNode[]
/** The session HAS children in the data (the twist renders even while folded). */
hasChildren: boolean
expanded: boolean
running: boolean
updatedAt: number
}
/** One flat sidebar list row. */
export type SidebarRow = ProjectRow | SessionRow
/** One workspace group section: header row facts + the visible session tree. */
export interface GroupNode {
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
key: string
/** Backing Workspace id; absent only for the ungrouped bucket. */
workspaceId: WorkspaceId | undefined
cwd: string | undefined
label: string
/** Total sessions in the group, including hidden ones. */
sessionCount: number
expanded: boolean
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
containsCurrent: boolean
/** The frontend Session Intent points here: render one "New session" row. */
intentHere: boolean
/** Visible roots (empty while the group is folded). */
sessions: readonly SessionNode[]
}
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
export interface TreeView {
@@ -46,17 +51,18 @@ export interface TreeView {
interface Group {
key: string
workspaceId: WorkspaceId | undefined
cwd: string | undefined
label: string
summaries: Map<SessionId, SessionSummary>
roots: SessionId[]
children: Map<SessionId, SessionId[]>
latest: number
}
/**
* Project display label: basename of the group directory.
* @param cwd - project directory, or undefined for the ungrouped bucket.
* Directory display label: basename of the path (both separators accepted).
* Ungrouped-bucket fallback for surfaces without a workspace title.
* @param cwd - directory path, or undefined for the ungrouped bucket.
* @returns basename, the raw cwd when it has no basename, or the ungrouped label.
*/
export function projectLabel(cwd: string | undefined): string {
@@ -71,32 +77,32 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
return a.id < b.id ? -1 : 1
}
function groupByCwd(list: SessionListState): Group[] {
const byKey = new Map<string, SessionSummary[]>()
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined) continue
const key = s.cwd ?? UNGROUPED_KEY
const members = byKey.get(key)
if (members === undefined) byKey.set(key, [s])
else members.push(s)
}
const groups: Group[] = []
for (const [key, members] of byKey) {
const summaries = new Map(members.map(m => [m.id, m]))
const children = new Map<SessionId, SessionId[]>()
const roots: SessionSummary[] = []
for (const m of members) {
// A session is a tree child only when its parent lives in the same
// group; cross-group or unknown parents degrade to group roots.
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
const kids = children.get(m.parentId)
if (kids === undefined) children.set(m.parentId, [m.id])
else kids.push(m.id)
} else {
roots.push(m)
}
/** Build one group's parent/child tree from an ordered member list. */
function buildGroup(
key: string,
workspaceId: WorkspaceId | undefined,
cwd: string | undefined,
label: string,
members: readonly SessionSummary[],
order: 'account' | 'recency',
): Group {
const summaries = new Map(members.map(m => [m.id, m]))
const children = new Map<SessionId, SessionId[]>()
const roots: SessionSummary[] = []
for (const m of members) {
// A session is a tree child only when its parent lives in the same
// group; cross-group or unknown parents degrade to group roots.
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
const kids = children.get(m.parentId)
if (kids === undefined) children.set(m.parentId, [m.id])
else kids.push(m.id)
} else {
roots.push(m)
}
}
// Workspace order is the member iteration order (workspace.sessionIds), so
// attached groups keep insertion order; Ungrouped sorts by recency.
if (order === 'recency') {
roots.sort(byRecency)
for (const kids of children.values()) {
kids.sort((a, b) => {
@@ -107,48 +113,63 @@ function groupByCwd(list: SessionListState): Group[] {
return byRecency(sa, sb)
})
}
const rootIds = roots.map(r => r.id)
// parentId cycles (host bug) leave members unreachable from any root;
// surface them as extra roots — the flatten walk's visited set stops
// loops. Each node sits in at most one kids list and roots have no
// in-group parent, so the scan pushes every reachable node exactly once.
const reachable = new Set<SessionId>(rootIds)
const stack = [...rootIds]
while (stack.length > 0) {
const top = stack.pop()
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
if (top === undefined) break
for (const kid of children.get(top) ?? []) {
reachable.add(kid)
stack.push(kid)
}
}
for (const m of [...members].sort(byRecency)) {
if (!reachable.has(m.id)) rootIds.push(m.id)
}
let latest = 0
for (const m of members) latest = Math.max(latest, m.updatedAt)
groups.push({
key,
cwd: key === UNGROUPED_KEY ? undefined : key,
label: projectLabel(key === UNGROUPED_KEY ? undefined : key),
summaries,
roots: rootIds,
children,
latest,
})
}
groups.sort((a, b) => b.latest - a.latest || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))
const rootIds = roots.map(r => r.id)
// parentId cycles (host bug) leave members unreachable from any root;
// surface them as extra roots — the flatten walk's visited set stops
// loops. Each node sits in at most one kids list and roots have no
// in-group parent, so the scan pushes every reachable node exactly once.
const reachable = new Set<SessionId>(rootIds)
const stack = [...rootIds]
while (stack.length > 0) {
const top = stack.pop()
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
if (top === undefined) break
for (const kid of children.get(top) ?? []) {
reachable.add(kid)
stack.push(kid)
}
}
for (const m of members) {
if (!reachable.has(m.id)) rootIds.push(m.id)
}
return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
}
/**
* Group Sessions by Host Workspace: one group per entity in stable Host
* order, with members resolved from sessionIds in their stored order. Sessions
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
*/
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
const groups: Group[] = []
const accounted = new Set<SessionId>()
for (const workspace of workspaces) {
const members: SessionSummary[] = []
for (const id of workspace.sessionIds) {
const summary = list.byId[id]
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
members.push(summary)
accounted.add(id)
}
groups.push(buildGroup(
workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
))
}
const stray = list.ids
.map(id => list.byId[id])
.filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id))
if (stray.length > 0) {
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
}
return groups
}
function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boolean, expanded: boolean): SessionRow {
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
return {
type: 'session',
id: s.id,
groupKey: g.key,
title: s.displayTitle,
depth,
children,
hasChildren,
expanded,
running: s.running,
@@ -156,20 +177,20 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo
}
}
function flattenVisible(g: Group, expandedSessions: ReadonlySet<string>, rows: SidebarRow[]): void {
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId, depth: number): void => {
if (visited.has(id)) return
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return
if (s === undefined) return null
const kids = g.children.get(id) ?? []
const expanded = expandedSessions.has(id)
rows.push(sessionRow(g, s, depth, kids.length > 0, expanded))
if (expanded) for (const kid of kids) walk(kid, depth + 1)
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
return sessionNode(s, children, kids.length > 0, expanded)
}
for (const root of g.roots) walk(root, 0)
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/** Matched sessions plus their ancestor chains (forced visible under search). */
@@ -186,66 +207,98 @@ function searchVisible(g: Group, q: string): Set<SessionId> {
return visible
}
function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarRow[]): void {
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId, depth: number): void => {
if (visited.has(id) || !visible.has(id)) return
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id) || !visible.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return
if (s === undefined) return null
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
rows.push(sessionRow(g, s, depth, kids.length > 0, kids.length > 0))
for (const kid of kids) walk(kid, depth + 1)
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
return sessionNode(s, children, kids.length > 0, kids.length > 0)
}
for (const root of g.roots) walk(root, 0)
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/**
* Derive the flat sidebar row list.
* Derive the nested sidebar group structure.
*
* Normal mode: every project row shows; sessions show under expanded
* projects, descending only into expanded sessions. Search mode (non-blank
* query, case-insensitive display-title substring): expansion state is ignored —
* Normal mode: every group shows; sessions populate under expanded groups,
* descending only into expanded sessions. A frontend Session Intent targeting
* a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query,
* case-insensitive display-title substring): expansion state is ignored —
* matched sessions and their ancestor chains are forced visible, groups
* without a display-title or label hit are dropped, and a label-only hit keeps the
* bare project row.
* @param list - sessions list snapshot.
* without a display-title or label hit are dropped, a label-only hit keeps
* the bare group header, and Intent rows do not participate.
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param view - local expansion arrays and search query.
* @returns rows in render order.
* @returns group sections in render order.
*/
export function deriveRows(list: SessionListState, view: TreeView): SidebarRow[] {
export function deriveGroups(
list: SessionListState,
workspaces: readonly WorkspaceView[],
view: TreeView,
): GroupNode[] {
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)) {
const intent = list.intent
const intentWorkspaceId = intent?.target.kind === 'workspace'
? intent.target.workspaceId
: undefined
const currentAccount = list.current === undefined
? undefined
: workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined
const currentGroup = list.current === undefined
? undefined
: intent?.sessionId === list.current
? intentWorkspaceId
: currentAccount ?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces)) {
const hasIntent = intentWorkspaceId !== undefined
&& g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId
const intentHere = q === '' && hasIntent
if (q === '') {
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, expandedSessions, rows)
const expanded = intentHere || expandedProjects.has(g.key)
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
label: g.label,
sessionCount: g.summaries.size + (hasIntent ? 1 : 0),
expanded,
containsCurrent: g.key === currentGroup,
intentHere,
sessions: expanded ? buildVisible(g, expandedSessions) : [],
})
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
rows.push({
type: 'project',
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
label: g.label,
sessionCount: g.summaries.size,
sessionCount: g.summaries.size + (hasIntent ? 1 : 0),
expanded: visible.size > 0,
containsCurrent: g.key === currentGroup,
intentHere: false,
sessions: buildSearch(g, visible),
})
flattenSearch(g, visible, rows)
}
}
return rows
return groups
}
/**
* Relative time label for session rows (figma samples: now / 2min / 1h / 2d / 18d / 2mo).
* @param updatedAt - epoch ms of the last update.
* @param now - current epoch ms.
* @returns compact age label.
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
* @param updatedAt - epoch ms of the session's last activity.
* @param now - current epoch ms (injected for pure rendering).
* @returns the row's trailing time label.
*/
export function formatRelativeTime(updatedAt: number, now: number): string {
const MIN = 60_000

View File

@@ -1,117 +1,60 @@
/**
* 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.
*/
/** Sidebar slot registration and its plain runtime/layout callbacks. */
import { Context } from 'cordis'
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
async function bench() {
async function bench(declare = true) {
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')),
open: vi.fn(),
clear: vi.fn(),
}
const layout = { toggleSidebar: vi.fn() }
ctx.provide('sessions', sessions)
const sessions = { open: vi.fn() }
const workspaces = { startSession: vi.fn() }
ctx.provide('layout', layout)
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspaces as never)
const slots = ctx.get('slots') as SlotsService
// The sidebar slot exists only while its declaring entry is live.
slots.register(
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
return { ctx, slots, sessions, layout }
if (declare) {
slots.register(
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
}
return { ctx, slots, layout, sessions, workspaces }
}
/** 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', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions'])
describe('ui-sidebar apply', () => {
it('declares only the services it uses', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
})
it('fails loud when mounted without the inject declaration', async () => {
// ctx.slots rides the cordis property proxy: reading it from a plugin
// that never declared the dependency throws instead of yielding undefined.
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
it('registers the sidebar and declares its Workspace picker hole', async () => {
const b = await bench()
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('sidebar')).toHaveLength(1)
expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' })
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar'])
injected.startSession('workspace' as never, 'prompt')
expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt')
injected.open('session' as never)
expect(b.sessions.open).toHaveBeenCalledWith('session')
injected.toggleSidebar()
expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
})
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('fails when no live owner declared the sidebar slot', async () => {
const b = await bench(false)
await expect(b.ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/not declared/)
})
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()
const injected = injectedOf(slots)
injected.onToggleSidebar()
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
injected.onOpen(sid('a'))
expect(sessions.open).toHaveBeenCalledWith('a')
injected.onCreate()
expect(sessions.clear).toHaveBeenCalledOnce()
expect(sessions.create).not.toHaveBeenCalled()
injected.onCreate('/proj')
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
// create-then-open lands after the create promise resolves.
await Promise.resolve()
await Promise.resolve()
expect(sessions.open).toHaveBeenCalledWith('minted')
})
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
it('removes the entry and child declaration on teardown', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('sidebar')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('sidebar')).toHaveLength(0)
expect(b.slots.entries('sidebar')).toHaveLength(0)
expect(b.slots.spec('sidebar.workspace')).toBeUndefined()
})
})

View File

@@ -1,292 +1,82 @@
// @vitest-environment jsdom
/**
* 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, useSyncExternalStore } from 'react'
// Runtime is React-free, so the spec binds its selector locally.
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
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
/** 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, current: undefined }
}
afterEach(cleanup)
function mount(...summaries: SessionSummary[]) {
// 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
collapsed={collapsed}
width={width}
useSessions={hookOf(sessions)}
onOpen={onOpen}
onCreate={onCreate}
onToggleSidebar={onToggleSidebar}
/>
)
const onToggleSidebar = vi.fn(() => {
collapsed = !collapsed
utils.rerender(view(collapsed ? 56 : 300))
})
const utils = render(view(300))
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
const workspace: WorkspaceView = {
workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}
const sessions: SessionListState = {
ids: [sid('s1')],
byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } },
current: undefined, phase: 'ready',
intent: undefined,
}
const workspaces: WorkspaceListState = {
items: [workspace], state: 'idle', phase: 'ready', error: null,
intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId,
}
const projectData = () => [
summary({ id: 'root', title: 'root work', cwd: '/proj', updatedAt: 5 }),
summary({ id: 'kid', title: 'forked child', cwd: '/proj', parentId: sid('root'), updatedAt: 4 }),
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() }) }
/** The brand wordmark is decorative svg (aria-hidden, no text); locate it by its native viewBox. */
const wordmark = () => document.querySelector('svg[viewBox="0 0 182 24"]')
function mount(sessionState: SessionListState = sessions) {
const startSession = vi.fn()
const open = vi.fn()
let pickerOwner: unknown
const view = render(
<SidebarRoot
collapsed={false} width={300}
useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
startSession={startSession} open={open} toggleSidebar={vi.fn()}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
/>,
)
return { view, startSession, open, pickerOwner: () => pickerOwner }
}
describe('SidebarRoot', () => {
it('renders chrome and collapsed project rows', () => {
mount(...projectData())
expect(wordmark()).not.toBeNull()
expect(screen.getByText('New Session')).toBeTruthy()
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('2 sessions')).toBeTruthy()
expect(screen.getByText('1 session')).toBeTruthy()
expect(screen.queryByText('root work')).toBeNull()
it('renders real Workspaces from useWorkspaces and routes New Session', () => {
const b = mount()
expect(screen.getByText('Project')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
expect(b.startSession).toHaveBeenCalledWith()
})
it('expands a project on click and unfolds a subtree via the twist', () => {
mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
expect(screen.getByText('root work')).toBeTruthy()
expect(screen.queryByText('forked child')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
expect(screen.getByText('forked child')).toBeTruthy()
act(() => { fireEvent.click(screen.getByLabelText('Collapse')) })
expect(screen.queryByText('forked child')).toBeNull()
})
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(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')
})
it('search filters across groups and forces ancestor chains visible', () => {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
expect(screen.getByText('forked child')).toBeTruthy()
expect(screen.getByText('root work')).toBeTruthy()
expect(screen.queryByText('elsewhere')).toBeNull()
expect(screen.queryByText(/^other$/)).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Clear search')) })
expect(screen.queryByText('root work')).toBeNull()
expect(screen.getByText('proj')).toBeTruthy()
})
it('shows the blank-list empty state without a query', () => {
mount()
expect(screen.getByText('No sessions yet')).toBeTruthy()
})
it('shows the no-match empty state', () => {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'zzz-none' } }) })
expect(screen.getByText('No matches')).toBeTruthy()
})
it('routes the three creation entries with the right cwd', () => {
const { onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('New Session')) })
expect(onCreate).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
expect(onCreate).toHaveBeenLastCalledWith()
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
expect(onCreate).toHaveBeenLastCalledWith('/proj')
})
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(wordmark()).not.toBeNull()
expect(screen.getByRole('tree')).toBeTruthy()
// Settle: wide content unmounts, the rail controls remain.
act(() => { vi.advanceTimersByTime(300) })
expect(wordmark()).toBeNull()
expect(screen.queryByText('New Session')).toBeNull()
expect(screen.queryByRole('tree')).toBeNull()
// Rail order mirrors the expanded rows: open, new session, new workspace, search.
const rail = ['Open 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('Open 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())
// While expanded the search control is inert (the row click focuses instead).
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(onToggleSidebar).not.toHaveBeenCalled()
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { vi.advanceTimersByTime(300) })
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
// Focus waits out the 300ms column slide (EXPAND_SLIDE_MS).
act(() => { vi.advanceTimersByTime(300) })
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('Open 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()
expect(screen.getByText('Status')).toBeTruthy()
// Selecting the active strategy closes the list (only workspace is enabled).
act(() => { fireEvent.click(screen.getByText('WorkSpace', { selector: 'button *' })) })
expect(screen.queryByText('Update')).toBeNull()
// Reopen and dismiss via Escape (Menu onClose channel).
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
expect(screen.queryByText('Update')).toBeNull()
})
it('re-renders when the sessions list gains a session', async () => {
const { sessions } = mount(...projectData())
act(() => {
sessions.update((draft) => {
draft.ids.push(sid('fresh'))
draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
})
it('shows a frontend Session under its real Workspace and routes its row plus', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const }
const b = mount({
...sessions,
current: intent.sessionId,
intent,
})
// Store notifications are microtask-batched.
await flush()
expect(screen.getByText('fresh')).toBeTruthy()
expect(screen.getByText('New session')).toBeTruthy()
expect(screen.getByText('2 sessions')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
})
it('row "More" anchors swallow the click without opening or toggling', () => {
const { onOpen } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
// Project-row anchor: must not collapse the project (rows stay visible).
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
expect(screen.getByText('root work')).toBeTruthy()
// Session-row anchor: must not open the session.
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
expect(onOpen).not.toHaveBeenCalled()
it('forwards Workspace picker selection and closes the picker', () => {
const b = mount()
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
expect(owner.open).toBe(true)
owner.onPick(workspace.workspaceId)
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
})
it('shows the running state dot only for running sessions', () => {
mount(
summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 2 }),
summary({ id: 'idle', title: 'idle one', cwd: '/p', updatedAt: 1 }),
)
act(() => { fireEvent.click(screen.getByText('p')) })
const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')!
const idleRow = screen.getByText('idle one').closest('[role="treeitem"]')!
expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy()
expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull()
it('opens a real Session through the owner action', () => {
const b = mount({ ...sessions, current: sid('intent'), intent: {
sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready',
} })
fireEvent.click(screen.getByText('Project'))
fireEvent.click(screen.getByText('First session'))
expect(b.open).toHaveBeenCalledWith(sid('s1'))
})
})

View File

@@ -1,245 +1,74 @@
import { describe, expect, it } from 'vitest'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL,
type SessionRow, type TreeView,
} from '../src/client/tree.ts'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveGroups, formatRelativeTime, UNGROUPED_KEY } from '../src/client/tree.ts'
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
interface SummaryInit {
id: string
title?: string
displayTitle?: string
cwd?: string
parentId?: string
running?: boolean
updatedAt?: number
}
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
displayTitle: init.displayTitle ?? init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.title !== undefined) s.title = init.title
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
}
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, current: undefined }
}
const view = (partial: Partial<TreeView> = {}): TreeView => ({
expandedProjects: partial.expandedProjects ?? [],
expandedSessions: partial.expandedSessions ?? [],
query: partial.query ?? '',
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
})
const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
intent: undefined,
})
const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title: id,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const view = (expandedProjects: readonly string[] = [], query = '') => ({
expandedProjects, expandedSessions: [] as string[], query,
})
describe('projectLabel', () => {
it('takes the basename and survives trailing separators', () => {
expect(projectLabel('/home/me/proj')).toBe('proj')
expect(projectLabel('/home/me/proj/')).toBe('proj')
expect(projectLabel('C:\\work\\thing')).toBe('thing')
describe('deriveGroups', () => {
it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
const sessions = list(summary('newer', 20), summary('older', 10))
const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
const groups = deriveGroups(sessions, workspaces, view(['first']))
expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
})
it('falls back for empty and root-only paths', () => {
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
expect(projectLabel('')).toBe(UNGROUPED_LABEL)
expect(projectLabel('///')).toBe('///')
})
})
describe('deriveRows grouping', () => {
it('groups by cwd into project rows with counts, newest group first', () => {
const rows = deriveRows(listOf(
summary({ id: 'a', cwd: '/x/alpha', updatedAt: 10 }),
summary({ id: 'b', cwd: '/x/beta', updatedAt: 30 }),
summary({ id: 'c', cwd: '/x/alpha', updatedAt: 20 }),
), view())
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/x/beta', label: 'beta', sessionCount: 1, expanded: false }),
expect.objectContaining({ type: 'project', key: '/x/alpha', label: 'alpha', sessionCount: 2 }),
])
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
})
it('orders equally-recent groups by label and skips ids missing from byId', () => {
const list = listOf(
summary({ id: 'b1', cwd: '/x/beta', updatedAt: 5 }),
summary({ id: 'a1', cwd: '/x/alpha', updatedAt: 5 }),
// Same basename and same recency as beta: label comparator returns 0,
// insertion order breaks the tie.
summary({ id: 'b2', cwd: '/y/beta', updatedAt: 5 }),
)
list.ids.push(sid('ghost'))
const rows = deriveRows(list, view())
expect(rows.map(r => r.type === 'project' && r.key)).toEqual(['/x/alpha', '/x/beta', '/y/beta'])
})
it('buckets cwd-less sessions under the ungrouped project row', () => {
const rows = deriveRows(listOf(summary({ id: 'a' })), view())
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: UNGROUPED_KEY, cwd: undefined, label: UNGROUPED_LABEL }),
])
})
it('hides sessions under collapsed projects and shows them when expanded', () => {
const list = listOf(
summary({ id: 'a', cwd: '/p', updatedAt: 1 }),
summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
)
expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
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 }),
])
})
})
describe('deriveRows session tree', () => {
const treeList = listOf(
summary({ id: 'root', cwd: '/p', updatedAt: 5 }),
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
summary({ id: 'grandkid', cwd: '/p', parentId: sid('kid'), updatedAt: 3 }),
summary({ id: 'other', cwd: '/p', updatedAt: 9 }),
)
it('nests children under expanded parents with increasing depth', () => {
const rows = deriveRows(treeList, view({
expandedProjects: ['/p'],
expandedSessions: ['root', 'kid'],
it('shows one frontend Session row only under a real target Workspace', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
const target = workspace('first', [])
expect(deriveGroups({ ...list(), current: intent.sessionId, intent }, [target], view())[0]).toEqual(expect.objectContaining({
intentHere: true,
sessionCount: 1,
containsCurrent: true,
}))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
expect.objectContaining({ id: 'root', depth: 0, hasChildren: true, expanded: true }),
expect.objectContaining({ id: 'kid', depth: 1, hasChildren: true, expanded: true }),
expect.objectContaining({ id: 'grandkid', depth: 2, hasChildren: false }),
])
const hiddenIntent = { sessionId: sid('zero'), target: { kind: 'workspace-intent' as const }, prompt: '', phase: 'ready' as const }
expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false)
})
it('collapses subtrees at unexpanded sessions', () => {
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'])
})
it('degrades a cross-group parent link to a group root', () => {
const rows = deriveRows(listOf(
summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
), view({ expandedProjects: ['/a', '/b'] }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/a' }),
expect.objectContaining({ id: 'p1', depth: 0 }),
expect.objectContaining({ type: 'project', key: '/b' }),
expect.objectContaining({ id: 'stray', depth: 0 }),
])
})
it('keeps cycle members visible as extra roots without looping', () => {
const rows = deriveRows(listOf(
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: ['/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')
expect(ids).toContain('y')
expect(ids).toHaveLength(3)
})
it('breaks updatedAt ties deterministically by id', () => {
const rows = deriveRows(listOf(
summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
), view({ expandedProjects: ['/p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['a', 'b', 'c'])
})
it('collects multiple children under one parent in recency order', () => {
const rows = deriveRows(listOf(
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: ['/p'], expandedSessions: ['p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['p', 'new', 'old'])
})
it('carries the running flag onto rows', () => {
const rows = deriveRows(
listOf(summary({ id: 'a', cwd: '/p', running: true })),
view({ expandedProjects: ['/p'] }))
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
})
})
describe('deriveRows search', () => {
const list = listOf(
summary({ id: 'root', title: 'alpha work', cwd: '/p', updatedAt: 5 }),
summary({ id: 'kid', title: 'deep needle here', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
summary({ id: 'noise', title: 'unrelated', cwd: '/p', updatedAt: 3 }),
summary({ id: 'q', title: 'quiet', cwd: '/other', updatedAt: 2 }),
)
it('forces matched sessions and their ancestor chains visible, ignoring expansion', () => {
const rows = deriveRows(list, view({ query: 'NEEDLE' }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/p', expanded: true }),
expect.objectContaining({ id: 'root', depth: 0, expanded: true }),
expect.objectContaining({ id: 'kid', depth: 1 }),
])
})
it('drops groups without a hit and keeps a bare project row on label-only hits', () => {
const rows = deriveRows(list, view({ query: 'other' }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/other', expanded: false }),
])
})
it('blank query means normal mode', () => {
const rows = deriveRows(list, view({ query: ' ' }))
expect(rows.every(r => r.type === 'project')).toBe(true)
})
it('matches the effective display title when no durable title is available', () => {
const fallback = listOf(summary({ id: 'raw-id', displayTitle: 'project fallback', cwd: '/elsewhere' }))
const rows = deriveRows(fallback, view({ query: 'fallback' }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/elsewhere' }),
expect.objectContaining({ type: 'session', id: 'raw-id', title: 'project fallback' }),
])
it('search filters real Sessions and omits the Intent placeholder', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const }
const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match'))
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('match')])
expect(groups[0]!.intentHere).toBe(false)
expect(groups[0]!.sessionCount).toBe(2)
})
})
describe('formatRelativeTime', () => {
const now = 1_000_000_000_000
it.each([
[now, 'now'],
[now - 30_000, 'now'],
[now - 2 * 60_000, '2min'],
[now - 3_600_000, '1h'],
[now - 2 * 86_400_000, '2d'],
[now - 18 * 86_400_000, '18d'],
[now - 65 * 86_400_000, '2mo'],
[now - 400 * 86_400_000, '1y'],
])('%d -> %s', (at, label) => {
expect(formatRelativeTime(at, now)).toBe(label)
})
it('clamps future timestamps to now', () => {
expect(formatRelativeTime(now + 5_000, now)).toBe('now')
it('formats current, minute, hour, day, month, and year buckets', () => {
const now = 400 * 24 * 60 * 60 * 1_000
expect(formatRelativeTime(now, now)).toBe('now')
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5min')
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe('3h')
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2d')
expect(formatRelativeTime(now - 60 * 86_400_000, now)).toBe('2mo')
expect(formatRelativeTime(0, now)).toBe('1y')
})
})