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:
imccyu
2026-07-26 00:02:46 +08:00
parent 84be7cc622
commit ea8b1178cd
48 changed files with 1948 additions and 1133 deletions
@@ -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`
}