feat(web): session list one-list, hover card, row menus, rename, manual ordering
Sidebar session list grows the figma 239-10458 feature set and the
workspace/session browsing region moves wholesale into ui-workspace:
- Group-by menu (WorkSpace / In one list): flat mode lists every session
top-level, strictly newest-first; the choice persists across reloads.
- Session rows get a 500ms hover detail card (title / relative time /
status line) and a ... menu (Rename / Fork session / Delete session,
visual-only for now); workspace headers get ... with Rename (wired) and
Delete workspace (visual-only).
- workspace.rename RPC: trims, rejects duplicate titles on the create
chain (workspace-name-conflict), no-op on same title; modal dialog with
client-side duplicate pre-check.
- workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted
anchor appends): HTML5 drag reorder of root sessions inside a workspace
group; order truth stays host-side, the view refreshes from the
response/changed frame.
- Activity pinning removed: the session/event touchSession chain is gone;
workspace accounts are manually owned (new sessions prepend, explicit
reordering only). Contracts and tests updated, api catalog regenerated.
- ui-sidebar reduced to the column shell (brand, fold state machine, New
Session, Settings) exposing one sidebar.workspaces hole with a two-fact
owner share {wide, expandSidebar}; ui-workspace owns the whole region
(header, search, grouped/flat lists, dialogs, drag) plus the picker via
a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and
its deferral indirection are gone.
- ui-primitives: Menu gains label entries, danger rows, and
closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled
guard). Hover card and row menu never coexist.
This commit is contained in:
@@ -1,216 +0,0 @@
|
||||
/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px
|
||||
single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps
|
||||
are pure CSS: project folder -> chevron + action buttons; session time ->
|
||||
ellipsis button. */
|
||||
|
||||
.projectRow,
|
||||
.sessionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.projectRow:hover,
|
||||
.sessionRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.sessionRow.selected {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
/* Two-line row: the leading slot (folder/chevron), title, and trailing
|
||||
actions all top-align on the 20px first text line (figma cell) — content
|
||||
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */
|
||||
.projectRow {
|
||||
height: 54px;
|
||||
align-items: flex-start;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projectRow .rowActions {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px
|
||||
gap to the title — the slots butt together, so the row gap is zeroed and
|
||||
the title carries its own margins. */
|
||||
.sessionRow {
|
||||
height: 34px;
|
||||
gap: 0;
|
||||
/* Mount fade: session rows appear by unfolding a group (or the tree
|
||||
mounting). Stable row keys keep already-visible rows from replaying it. */
|
||||
animation: row-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.sessionRow .title {
|
||||
margin: 0 6px 0 4px;
|
||||
}
|
||||
|
||||
@keyframes row-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
.slot {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
|
||||
.folderActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* Project leading slot: folder by default, expand arrow on row hover. */
|
||||
.projectRow .chevron { display: none; }
|
||||
.projectRow:hover .chevron { display: inline-flex; }
|
||||
.projectRow:hover .folder { display: none; }
|
||||
|
||||
/* Expand arrow (filled triangle): points right closed, rotates to point down open. */
|
||||
.arrow {
|
||||
transition: transform 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.arrowOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.projectText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
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;
|
||||
}
|
||||
|
||||
.meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Trailing action buttons surface on hover only (figma 27:4668 / 27:4656):
|
||||
bare 16px glyphs, gap 12, tertiary grey. */
|
||||
.rowActions {
|
||||
flex: none;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.projectRow:hover .rowActions,
|
||||
.sessionRow:hover .rowActions {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sessionRow:hover .time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Session expand twist occupies the leading 16px slot; keep a spacer when absent
|
||||
so titles align across sibling rows. Duplicates the .iconButton reset instead
|
||||
of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which
|
||||
left the raw UA button box showing. */
|
||||
.twist {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.twist:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph
|
||||
stays one step darker (tertiary, #81858C) per the cell spec. Declared last
|
||||
to win over the composed .iconButton color. */
|
||||
.chevron,
|
||||
.twist {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sessionRow,
|
||||
.arrow {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Sidebar tree row components (figma Cell set 14:3080): pure presentational —
|
||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||
* time->ellipsis, action buttons) are CSS-only.
|
||||
*/
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTriangleRightFill14, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GroupNode, SessionNode } from './tree.ts'
|
||||
import { formatRelativeTime } from './tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
/** Indent step per tree level: one 16px slot (figma session cell). */
|
||||
const INDENT_STEP = 16
|
||||
|
||||
/**
|
||||
* 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 - start a frontend Session inside this Workspace.
|
||||
* @returns the row element.
|
||||
*/
|
||||
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}>
|
||||
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
|
||||
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
|
||||
</span>
|
||||
<span className={clsx(css.slot, css.chevron)}>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</span>
|
||||
<span className={css.projectText}>
|
||||
<span className={css.title}>{row.label}</span>
|
||||
<span className={css.meta}>{count}</span>
|
||||
</span>
|
||||
<span className={css.rowActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={`New session in ${row.label}`}
|
||||
onClick={(e) => { e.stopPropagation(); onCreate() }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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: (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.
|
||||
const ownRow = (
|
||||
<div
|
||||
className={clsx(css.sessionRow, selected && css.selected)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
|
||||
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
|
||||
onClick={() => { onOpen(node.id) }}
|
||||
>
|
||||
{row.hasChildren
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
||||
>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
: <span className={css.slot} />}
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{ownRow}
|
||||
{node.children.map(child => (
|
||||
<SessionNodeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
currentId={currentId}
|
||||
now={now}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,6 @@
|
||||
refresh straight into the collapsed state renders statically. */
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .searchButton,
|
||||
.railIn .foot {
|
||||
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
|
||||
}
|
||||
@@ -184,133 +183,9 @@
|
||||
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);
|
||||
}
|
||||
|
||||
.collapsed .sectionHeader {
|
||||
height: 36px;
|
||||
padding-left: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Search input: 38px capsule (figma 133:7649); collapsed it renders as 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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
margin: 0 2px 12px; /* bottom: 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;
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
|
||||
}
|
||||
|
||||
.collapsed .search {
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
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;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.collapsed .searchButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.collapsed .searchButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
/* Region seat: always mounted so the foot never moves; the browser inside
|
||||
handles its own wide/rail content. */
|
||||
.regionArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
@@ -318,60 +193,6 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
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);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical
|
||||
margins fold into the row so the hover pill spans the full 49px. */
|
||||
.foot {
|
||||
@@ -418,7 +239,6 @@
|
||||
.fading > *,
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .searchButton,
|
||||
.railIn .foot {
|
||||
transition: none;
|
||||
animation: none;
|
||||
|
||||
@@ -1,170 +1,38 @@
|
||||
/**
|
||||
* Collapse is a slide plus crossfade: content freezes at its expanded
|
||||
* width (inline style) and fades out in place while the sliding column
|
||||
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
|
||||
* the wide-only content (brand, labels, input, tree) unmounts, dropping
|
||||
* the sessions subscription, and the control rows snap to the 56px rail
|
||||
* (one icon each, same top-down order) fading in as the slide ends. Rail
|
||||
* search expands and focuses the search box.
|
||||
* Sidebar shell: column geometry only. Collapse is a slide plus crossfade:
|
||||
* content freezes at its expanded width (inline style) and fades out in place
|
||||
* while the sliding column (AppFrame grid tracks) clips it — nothing reflows
|
||||
* mid-slide. At settle the wide-only content unmounts and the control rows
|
||||
* snap to the 56px rail (one icon each, same top-down order) fading in as the
|
||||
* slide ends. The workspace/session browsing region between the New Session
|
||||
* button and the foot is the `sidebar.workspaces` registrant's; the shell
|
||||
* hands it the wide flag and an expand request callback.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
BrandWordmark, FishLogo,
|
||||
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
|
||||
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
|
||||
Menu, Tooltip,
|
||||
IconNewChatOutline16, IconPanelLeftOutline16, IconSettingsOutline14,
|
||||
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 { 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. */
|
||||
const COLLAPSE_SETTLE_MS = 150
|
||||
|
||||
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ id: 'workspace', label: 'Workspace' },
|
||||
// Only workspace grouping is implemented.
|
||||
{ id: 'update', label: 'Update', disabled: true },
|
||||
{ id: 'status', label: 'Status', disabled: true },
|
||||
]
|
||||
|
||||
/** 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' | '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, startSession, open, workspaces, query }: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
// 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()
|
||||
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label="Sessions">
|
||||
{groups.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
)}
|
||||
{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={open}
|
||||
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sidebar column.
|
||||
* Render the sidebar column shell.
|
||||
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
|
||||
* @returns the sidebar element tree.
|
||||
*/
|
||||
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.
|
||||
const [settled, setSettled] = useState(collapsed)
|
||||
@@ -186,19 +54,6 @@ export function SidebarRoot({
|
||||
const everWide = useRef(!collapsed)
|
||||
if (!collapsed) everWide.current = true
|
||||
|
||||
// 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) {
|
||||
const timer = window.setTimeout(() => {
|
||||
searchInput.current?.focus({ preventScroll: true })
|
||||
setSearchOnExpand(false)
|
||||
}, EXPAND_SLIDE_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}
|
||||
}, [collapsed, searchOnExpand])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
|
||||
@@ -238,82 +93,15 @@ export function SidebarRoot({
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div className={css.sectionHeader}>
|
||||
{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="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) },
|
||||
{/* The browsing region fills the column between the controls and the
|
||||
foot in both states; its rail icon column rides the same slot. */}
|
||||
<div className={css.regionArea}>
|
||||
{renderSlot('sidebar.workspaces', {
|
||||
wide,
|
||||
expandSidebar: () => { if (collapsed) toggleSidebar() },
|
||||
})}
|
||||
</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() }}>
|
||||
<Tooltip label="Search" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label="Search sessions"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }}
|
||||
>
|
||||
<IconSearchOutline16 size={wide ? 14 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{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}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
||||
<IconSettingsOutline14 size={wide ? 14 : 18} />
|
||||
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
|
||||
|
||||
@@ -1,69 +1,54 @@
|
||||
/**
|
||||
* Sidebar slot contract: the registrant-side props composition for the
|
||||
* 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.
|
||||
* layout-owned `sidebar` slot, plus the workspace-browser hole this shell
|
||||
* declares. The shell owns column geometry (fold state machine, brand row,
|
||||
* New Session, Settings); everything between the section header and the list
|
||||
* bottom is the `sidebar.workspaces` registrant's (ui-workspace).
|
||||
*/
|
||||
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, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
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.
|
||||
* The workspace/session browsing region: section header, search, the
|
||||
* grouped/flat session list, and every workspace dialog. Declared by this
|
||||
* package's 'sidebar' entry (declaring is claiming); ui-workspace
|
||||
* registers the browser.
|
||||
*/
|
||||
'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps }
|
||||
'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Owner share of the browser hole — the only facts crossing the shell/region
|
||||
* seam. Business data and actions arrive through the region's own inject.
|
||||
*/
|
||||
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
|
||||
export interface SidebarSectionOwnerProps {
|
||||
/** Shell fold-state output: wide renders the full browser, rail the icon column. */
|
||||
wide: boolean
|
||||
/** Rail icons request expansion; the browser rides the wide flip for focus. */
|
||||
expandSidebar: () => 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.
|
||||
* factory). The shell keeps only its own controls: starting a Session from
|
||||
* the New Session button and toggling the column.
|
||||
*/
|
||||
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.
|
||||
* Full component props: layout owner state/actions plus the browser hole's
|
||||
* render share and this package's injected callbacks. No store is registered.
|
||||
*/
|
||||
export type SidebarRootComponentProps =
|
||||
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected
|
||||
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces'> & SidebarRootInjected
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
/** Registers the sidebar UI into the layout-owned slot. */
|
||||
/** Registers the sidebar shell into the layout-owned slot. */
|
||||
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, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
|
||||
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps } from './contract/slots.ts'
|
||||
|
||||
/** Services required by the sidebar plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
export const inject = ['slots', 'layout', 'workspaces']
|
||||
|
||||
/** Registers the sidebar component and its service callbacks.
|
||||
/** Registers the sidebar shell and its service callbacks.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injectProps = (): SidebarRootInjected => ({
|
||||
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',
|
||||
// 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' } },
|
||||
// The shell owns geometry; ui-workspace registers the whole browsing
|
||||
// region (header, search, session list, workspace dialogs) here.
|
||||
children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' } },
|
||||
inject: injectProps,
|
||||
}, SidebarRoot),
|
||||
'ui-sidebar: slot registration',
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
/**
|
||||
* 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 outside every Workspace. */
|
||||
export const UNGROUPED_KEY = ''
|
||||
|
||||
/** Display label for the ungrouped bucket row. */
|
||||
export const UNGROUPED_LABEL = 'Ungrouped'
|
||||
|
||||
/** One session node of a group's visible tree (34px row; children render indented one step). */
|
||||
export interface SessionNode {
|
||||
id: SessionId
|
||||
title: string
|
||||
/** 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 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 {
|
||||
expandedProjects: readonly string[]
|
||||
expandedSessions: readonly string[]
|
||||
query: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
summaries: Map<SessionId, SessionSummary>
|
||||
roots: SessionId[]
|
||||
children: Map<SessionId, SessionId[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (cwd === undefined || cwd === '') return UNGROUPED_LABEL
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */
|
||||
function byRecency(a: SessionSummary, b: SessionSummary): number {
|
||||
if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
const sa = summaries.get(a)
|
||||
const sb = summaries.get(b)
|
||||
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
|
||||
if (sa === undefined || sb === undefined) return 0
|
||||
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) {
|
||||
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 sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.displayTitle,
|
||||
children,
|
||||
hasChildren,
|
||||
expanded,
|
||||
running: s.running,
|
||||
updatedAt: s.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
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 null
|
||||
const kids = g.children.get(id) ?? []
|
||||
const expanded = expandedSessions.has(id)
|
||||
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
|
||||
return sessionNode(s, children, kids.length > 0, expanded)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/** Matched sessions plus their ancestor chains (forced visible under search). */
|
||||
function searchVisible(g: Group, q: string): Set<SessionId> {
|
||||
const visible = new Set<SessionId>()
|
||||
for (const m of g.summaries.values()) {
|
||||
if (!m.displayTitle.toLowerCase().includes(q)) continue
|
||||
let cur: SessionSummary | undefined = m
|
||||
while (cur !== undefined && !visible.has(cur.id)) {
|
||||
visible.add(cur.id)
|
||||
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
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 null
|
||||
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
|
||||
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
return sessionNode(s, children, kids.length > 0, kids.length > 0)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the nested sidebar group structure.
|
||||
*
|
||||
* 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, 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 group sections in render order.
|
||||
*/
|
||||
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 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 = 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
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size + (hasIntent ? 1 : 0),
|
||||
expanded: visible.size > 0,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
intentHere: false,
|
||||
sessions: buildSearch(g, visible),
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
const HOUR = 3_600_000
|
||||
const DAY = 86_400_000
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < MIN) return 'now'
|
||||
if (diff < HOUR) return `${Math.floor(diff / MIN)}min`
|
||||
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`
|
||||
if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d`
|
||||
if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo`
|
||||
return `${Math.floor(diff / (365 * DAY))}y`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Sidebar slot registration and its plain runtime/layout callbacks. */
|
||||
/** Sidebar shell slot registration and its plain runtime/layout callbacks. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -9,10 +9,8 @@ async function bench(declare = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const layout = { toggleSidebar: vi.fn() }
|
||||
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
|
||||
if (declare) {
|
||||
@@ -21,25 +19,23 @@ async function bench(declare = true) {
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
return { ctx, slots, layout, sessions, workspaces }
|
||||
return { ctx, slots, layout, workspaces }
|
||||
}
|
||||
|
||||
describe('ui-sidebar apply', () => {
|
||||
it('declares only the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
|
||||
expect(inject).toEqual(['slots', 'layout', 'workspaces'])
|
||||
})
|
||||
|
||||
it('registers the sidebar and declares its Workspace picker hole', async () => {
|
||||
it('registers the shell and declares the browsing-region 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' })
|
||||
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar'])
|
||||
expect(Object.keys(injected)).toEqual(['startSession', '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()
|
||||
})
|
||||
@@ -55,6 +51,6 @@ describe('ui-sidebar apply', () => {
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
||||
expect(b.slots.spec('sidebar.workspace')).toBeUndefined()
|
||||
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
|
||||
describe('sidebar rows', () => {
|
||||
it('renders an active Workspace and keeps its create action separate from toggling', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
|
||||
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(onToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the frontend Intent placeholder as selected', () => {
|
||||
render(<IntentRowItem />)
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders and operates selected, running, recursive Session nodes', () => {
|
||||
const child: SessionNode = {
|
||||
id: sid('child'), title: 'Child', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const parent: SessionNode = {
|
||||
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
|
||||
expanded: true, running: true, updatedAt: 0,
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
|
||||
expect(parentRow.getAttribute('aria-selected')).toBe('true')
|
||||
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(childRow.getAttribute('aria-selected')).toBe('false')
|
||||
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(onToggle).toHaveBeenCalledWith(parent.id)
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
fireEvent.click(parentRow)
|
||||
fireEvent.click(childRow)
|
||||
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
|
||||
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
|
||||
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
|
||||
})
|
||||
})
|
||||
@@ -1,79 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type {
|
||||
SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
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,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
// The shell never reads the global hooks itself, but they ride the standard
|
||||
// props share; stub them as never-called functions.
|
||||
const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
|
||||
|
||||
function mountSidebar({
|
||||
sessionState = sessions,
|
||||
workspaceState = workspaces,
|
||||
collapsed = false,
|
||||
width = 300,
|
||||
}: {
|
||||
sessionState?: SessionListState
|
||||
workspaceState?: WorkspaceListState
|
||||
collapsed?: boolean
|
||||
width?: number
|
||||
} = {}) {
|
||||
function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
|
||||
const startSession = vi.fn()
|
||||
const open = vi.fn()
|
||||
const toggleSidebar = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
let current = { sessionState, workspaceState, collapsed, width }
|
||||
let regionOwner: SidebarSectionOwnerProps | undefined
|
||||
let current = { collapsed, width }
|
||||
const root = () => (
|
||||
<SidebarRoot
|
||||
collapsed={current.collapsed} width={current.width}
|
||||
useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
|
||||
startSession={startSession} open={open} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
|
||||
useSessions={neverHook} useWorkspaces={neverHook}
|
||||
startSession={startSession} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => {
|
||||
regionOwner = owner
|
||||
return <div data-testid="region" data-wide={owner.wide} />
|
||||
}) as SidebarRootComponentProps['renderSlot']}
|
||||
/>
|
||||
)
|
||||
const view = render(root())
|
||||
return {
|
||||
startSession,
|
||||
open,
|
||||
toggleSidebar,
|
||||
pickerOwner: () => pickerOwner,
|
||||
regionOwner: () => {
|
||||
if (regionOwner === undefined) throw new Error('region owner not rendered')
|
||||
return regionOwner
|
||||
},
|
||||
rerender(next: Partial<typeof current>) {
|
||||
current = { ...current, ...next }
|
||||
view.rerender(root())
|
||||
@@ -81,181 +44,40 @@ function mountSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
describe('SidebarRoot', () => {
|
||||
it('renders real Workspaces from useWorkspaces and routes New Session', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('Project')).toBeTruthy()
|
||||
describe('SidebarRoot shell', () => {
|
||||
it('routes New Session and the column toggle', () => {
|
||||
const b = mountShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
|
||||
expect(b.startSession).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
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('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('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'))
|
||||
})
|
||||
|
||||
it('opens, selects, dismisses, and toggles the group-by menu', () => {
|
||||
mount()
|
||||
const button = screen.getByRole('button', { name: 'Group by' })
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes every Workspace picker close path', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onClose(): void }
|
||||
expect(owner.open).toBe(true)
|
||||
act(() => { owner.onClose() })
|
||||
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
|
||||
})
|
||||
|
||||
it('focuses, filters, and clears search while distinguishing both empty states', () => {
|
||||
mount()
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
fireEvent.click(input.parentElement!)
|
||||
expect(document.activeElement).toBe(input)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
|
||||
fireEvent.change(input, { target: { value: 'missing' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(screen.queryByText('No matches')).toBeNull()
|
||||
|
||||
cleanup()
|
||||
const emptySessions = listState()
|
||||
const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined }
|
||||
mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces })
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toggles Workspace and nested Session expansion in both directions', () => {
|
||||
const parent = sid('parent')
|
||||
const child = sid('child')
|
||||
const nestedSessions: SessionListState = {
|
||||
...sessions,
|
||||
ids: [parent, child],
|
||||
byId: {
|
||||
[parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 },
|
||||
[child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent },
|
||||
},
|
||||
}
|
||||
const nestedWorkspace: WorkspaceListState = {
|
||||
...workspaces,
|
||||
items: [{ ...workspace, sessionIds: [parent, child] }],
|
||||
}
|
||||
mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace })
|
||||
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('Child')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('Child')).toBeNull()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(screen.queryByText('Parent')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not start a Session from an Ungrouped row create action', () => {
|
||||
const loose = sid('loose')
|
||||
const looseSessions: SessionListState = {
|
||||
...listState(),
|
||||
ids: [loose],
|
||||
byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } },
|
||||
current: loose,
|
||||
}
|
||||
const b = mountSidebar({
|
||||
sessionState: looseSessions,
|
||||
workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
|
||||
expect(b.startSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => {
|
||||
const b = mountSidebar()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] }
|
||||
b.rerender({
|
||||
sessionState: { ...sessions, current: sid('s1') },
|
||||
workspaceState: { ...workspaces, items: [other, workspace] },
|
||||
})
|
||||
expect(screen.getByText('First session')).toBeTruthy()
|
||||
|
||||
b.rerender({
|
||||
sessionState: {
|
||||
...sessions,
|
||||
current: sid('draft'),
|
||||
intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' },
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('Project')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the static collapsed rail and expands rail search into focused input', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountSidebar({ collapsed: true })
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledTimes(2)
|
||||
b.rerender({ collapsed: false })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('keeps wide content during live collapse, then settles to the rail', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountSidebar({ width: 320 })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
b.rerender({ collapsed: true, width: 56 })
|
||||
expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
|
||||
const b = mountShell()
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
// Expanded: the request is a no-op (no accidental collapse).
|
||||
b.regionOwner().expandSidebar()
|
||||
expect(b.toggleSidebar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the region mounted through collapse and expands on its request', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountShell()
|
||||
b.rerender({ collapsed: true })
|
||||
// Wide content survives the crossfade window, then settles into the rail.
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
vi.advanceTimersByTime(200)
|
||||
b.rerender({})
|
||||
expect(b.regionOwner().wide).toBe(false)
|
||||
expect(screen.getByTestId('region')).toBeTruthy()
|
||||
b.regionOwner().expandSidebar()
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders statically collapsed on a cold start (no crossfade classes)', () => {
|
||||
const b = mountShell({ collapsed: true })
|
||||
expect(b.regionOwner().wide).toBe(false)
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
function listState(): SessionListState {
|
||||
return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined }
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
|
||||
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('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('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('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,
|
||||
}))
|
||||
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('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)
|
||||
})
|
||||
|
||||
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
|
||||
const parent = summary('parent', 1)
|
||||
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
|
||||
const newChild = { ...summary('new-child', 20), parentId: parent.id }
|
||||
const tieB = { ...summary('tie-b', 20), parentId: parent.id }
|
||||
const tieA = { ...summary('tie-a', 20), parentId: parent.id }
|
||||
const self = { ...summary('self', 2), parentId: sid('self') }
|
||||
const orphan = { ...summary('orphan', 3), parentId: sid('missing') }
|
||||
const cycleA = { ...summary('cycle-a', 4), parentId: sid('cycle-b') }
|
||||
const cycleB = { ...summary('cycle-b', 5), parentId: sid('cycle-a') }
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
|
||||
sid('orphan'), sid('self'), parent.id, sid('cycle-a'),
|
||||
])
|
||||
expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([
|
||||
newChild.id, tieA.id, tieB.id, oldChild.id,
|
||||
])
|
||||
|
||||
// Equal timestamps use ids as a deterministic tiebreak in either input order.
|
||||
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]!
|
||||
.sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
|
||||
})
|
||||
|
||||
it('tolerates Workspace membership arriving before its Session summary', () => {
|
||||
const partial: SessionListState = {
|
||||
...list(),
|
||||
ids: [sid('present')],
|
||||
byId: { [sid('present')]: summary('present', 1) },
|
||||
}
|
||||
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project']))
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
|
||||
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
|
||||
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
|
||||
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
|
||||
const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') }
|
||||
const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') }
|
||||
const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') }
|
||||
const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') }
|
||||
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
|
||||
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
|
||||
|
||||
expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
|
||||
root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
|
||||
])
|
||||
|
||||
const labelOnly = deriveGroups(
|
||||
list(summary('hidden', 1)),
|
||||
[workspace('label-hit', ['hidden']), workspace('other', [])],
|
||||
view([], 'label'),
|
||||
)
|
||||
expect(labelOnly).toEqual([
|
||||
expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
|
||||
const owned = summary('owned', 1)
|
||||
const loose = summary('loose', 2)
|
||||
const ws = workspace('project', ['owned'])
|
||||
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view())
|
||||
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
|
||||
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view())
|
||||
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectLabel', () => {
|
||||
it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
|
||||
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
|
||||
expect(projectLabel('')).toBe(UNGROUPED_LABEL)
|
||||
expect(projectLabel('/projects/demo/')).toBe('demo')
|
||||
expect(projectLabel('C:\\projects\\demo\\')).toBe('demo')
|
||||
expect(projectLabel('/')).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user