Merge branch 'code-mode-ui/live-parallel' into code-mode-ui/dispatch-spill

Conflict resolution: scripts/type-equiv.manifest.json takes master's new
paired-derivative format (one primary entry per pair) and re-adds this
stack's CodeDispatchLog entry in that format. zh README pairs brought
along for the dispatch-log arm (spill-policy behavior/limitations bullets,
tools limitation bullet now pointing at the shipped bounding).
This commit is contained in:
Tianyi Cui
2026-07-26 21:35:03 +08:00
965 changed files with 23842 additions and 4514 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33
README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010

View File

@@ -1,5 +1,7 @@
# @deepseek-ai/dsh-client-ui-workspace
English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization.

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-client-ui-workspace
[English](README.md) | 中文
共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
## 模型体验
无。选择器属于浏览器 chrome;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。
- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。

View File

@@ -35,6 +35,9 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",

View File

@@ -0,0 +1,265 @@
/* Workspace browsing region (fills the sidebar shell's hole): section
header, search capsule, and the scrolling session list. Wide/rail
variants ride the shell's fold state through the `wide` owner prop —
rail state renders only the two 36x36 icon controls. */
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.iconButton {
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);
}
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Section header: 36px, "Workspaces/Sessions" 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);
}
.sectionLabel {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
line-height: 20px;
}
/* Search input: 38px capsule (figma 133:7649); rail state renders it as the
region'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. */
.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;
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);
}
/* The capsule's leading icon: decorative while wide (pointer-events off so
clicks reach the input), the hit target in rail state. */
.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;
}
.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);
}
/* Rail variant (own .rail class from the wide owner prop — the region never
reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. */
.rail .sectionHeader {
padding-left: 0;
margin-bottom: 12px;
}
.rail .iconButton {
width: 36px;
height: 36px;
color: var(--dsw-alias-label-primary);
}
.rail .search {
height: 36px;
padding: 0;
margin: 0 0 12px;
gap: 0;
border-color: transparent;
background: transparent;
}
.rail .searchButton {
width: 36px;
height: 36px;
pointer-events: auto;
cursor: pointer;
color: var(--dsw-alias-label-primary);
}
.rail .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* List seat: always mounted so the shell foot never moves. */
.listArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
}
/* Wide-only content fades back in on expand remount (mirrors the shell). */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
}
@keyframes wide-in {
from { opacity: 0; }
}
/* List: the only scrolling region. Block, not a flex column: as flex items
the 54/34 rows would shrink under content overflow; 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;
}
/* Rename dialog form (same figma dialog family as the create modals). */
.renameInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.renameInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.renameError {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
@media (prefers-reduced-motion: reduce) {
.wide {
animation: none;
}
}

View File

@@ -0,0 +1,433 @@
/**
* The workspace/session browsing region filling the sidebar shell's
* `sidebar.workspaces` hole: section header (title + group-by + new
* workspace), search, the grouped tree or flat list, and the workspace
* dialogs. Wide state renders the full browser; rail state renders the two
* region icons (search / new workspace), each requesting shell expansion
* through the owner share. The picker menu and create dialogs live in
* WorkspacePicker (same package — direct composition, no slot between them).
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
Button, IconCloseFill14, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserProps } from './contract/slots.ts'
import type { SessionNode } from './tree.ts'
import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts'
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
/** 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 = [
{ type: 'label' as const, id: 'group-by', text: 'Group by' },
{ id: 'workspace', label: 'WorkSpace' },
{ id: 'flat', label: 'In one list' },
]
/** 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({ groupBy, onPick }: {
groupBy: 'workspace' | 'flat'
onPick: (mode: 'workspace' | 'flat') => void
}) {
const [open, setOpen] = useState(false)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId={groupBy}
onSelect={(id) => {
/* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */
if (id === 'workspace' || id === 'flat') onPick(id)
setOpen(false)
}}
align="end"
// Portal: the section header clips overflow, so an in-place list would
// be cut off at the header's bounds.
portal
anchor={(
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
onClick={() => { setOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
)
}
/** In-flight root-row drag: source identity plus the current insert marker. */
interface DragState {
workspaceId: WorkspaceId
sessionId: SessionNode['id']
/** Row the marker sits on and which half (insert above/below it). */
over: { id: SessionNode['id']; half: 'before' | 'after' } | null
}
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
> & {
workspaces: readonly WorkspaceView[]
/** Live search filter owned by the browser root (the query outlives the tree). */
query: string
/** Open the browser-owned rename dialog for a real Workspace group. */
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
const list = useSessions((s) => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
// Transient drag viewing state (never store-bound; order truth stays Host-side).
const [drag, setDrag] = useState<DragState | null>(null)
// 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 (WorkspaceBrowser.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)
}}
onRename={group.workspaceId === undefined
? undefined
: () => {
/* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
}}
/>
{group.expanded && group.intentHere && <IntentRowItem />}
{group.sessions.map((node, index) => {
// Draggable: real-workspace group roots outside search. The drag
// never leaves its group — rows of other groups show no markers
// and reject drops (visual movement confined to this section).
const draggable = group.workspaceId !== undefined && query === ''
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
start: () => {
setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
},
active: sameGroupDrag,
marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
hover: (half: 'before' | 'after') => {
/* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */
setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } }))
},
drop: (half: 'before' | 'after') => {
/* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
if (drag === null) return
const roots = group.sessions
// Anchor = the row the insert line points at ('after' means
// the next root; end-of-list omits the anchor → append).
const anchor = half === 'before' ? node.id : roots[index + 1]?.id
setDrag(null)
if (anchor === drag.sessionId) return
// No-op when the drop lands back on the source position.
const sourceIndex = roots.findIndex(r => r.id === drag.sessionId)
const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor)
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => {
console.warn('session reorder rejected:', reason)
})
},
end: () => { setDrag(null) },
}
return (
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={current}
now={now}
onOpen={open}
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
drag={dragProps}
/>
)
})}
</div>
))}
</div>
<span className={css.fade} />
</div>
)
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
const list = useSessions((s) => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now()
// The intent placeholder renders outside search only; it suppresses the
// empty state only while actually rendered (a query hides both).
const intentRow = query === '' && list.intent !== undefined
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
{rows.length === 0 && !intentRow && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
)}
{intentRow && <IntentRowItem />}
{rows.map(node => (
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={list.current}
now={now}
onOpen={open}
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
onToggle={() => {}}
flat
/>
))}
</div>
<span className={css.fade} />
</div>
)
}
/**
* Render the browsing region.
* @param props - composed slot props (shell owner share + store + injected actions).
* @returns the region element tree.
*/
export function WorkspaceBrowser({
wide,
expandSidebar,
useSessions,
useWorkspaces,
useStore,
actions,
startSession,
open,
renameWorkspace,
insertSessionBefore,
createWorkspace,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
// 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 picker menu (same popover in wide and rail
// states; the menu anchors on this button).
const [wsPickerOpen, setWsPickerOpen] = useState(false)
const wsPlusRef = useRef<HTMLButtonElement>(null)
// Rail search = expand + land in the search box: the flag arms before the
// expand request; once the shell flips wide the input mounts and takes focus.
const [searchOnExpand, setSearchOnExpand] = useState(false)
useEffect(() => {
if (wide && searchOnExpand) {
const timer = window.setTimeout(() => {
searchInput.current?.focus({ preventScroll: true })
setSearchOnExpand(false)
}, EXPAND_SLIDE_MS)
return () => { window.clearTimeout(timer) }
}
}, [wide, searchOnExpand])
// Rename dialog (browser-owned so it outlives row unmounts during collapse).
const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const [renaming, setRenaming] = useState(false)
const [renameError, setRenameError] = useState<string | null>(null)
const renameTrimmed = renameDraft.trim()
const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle
&& workspaces.some(w => w.title === renameTrimmed)
const renameBlocked = renaming || renameTrimmed === ''
|| renameTarget === null || renameTrimmed === renameTarget.currentTitle || renameDuplicate
const closeRename = () => {
if (renaming) return
setRenameTarget(null)
setRenameError(null)
}
const confirmRename = () => {
if (renameBlocked || renameTarget === null) return
setRenaming(true)
setRenameError(null)
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
setRenaming(false)
setRenameTarget(null)
}).catch((reason: unknown) => {
setRenaming(false)
setRenameError(reason instanceof Error ? reason.message : String(reason))
})
}
return (
<div className={clsx(css.root, !wide && css.rail)}>
<div className={css.sectionHeader}>
{wide && (
<span className={clsx(css.sectionLabel, css.wide)}>
{groupBy === 'flat' ? 'Sessions' : 'Workspaces'}
</span>
)}
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} />}
<Tooltip label="New Workspace" disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label="Create workspace"
onClick={() => {
if (!wide) expandSidebar()
setWsPickerOpen(v => !v)
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
{/* Picker menu + create dialogs (same package — direct composition). */}
<WorkspaceCreateFlow
open={wsPickerOpen}
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
}}
onClose={() => { setWsPickerOpen(false) }}
/>
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Rail: the icon is the region's search control. */}
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
<Tooltip label="Search" disabled={wide}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
tabIndex={wide ? -1 : 0}
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
>
<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 keeps the region's flex slot while the list
itself is wide-only. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} query={query} />
: (
<SessionTree
useSessions={useSessions}
workspaces={workspaces}
startSession={startSession}
open={open}
query={query}
insertSessionBefore={insertSessionBefore}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
setRenameError(null)
}}
/>
))}
</div>
<Modal
open={renameTarget !== null}
onClose={closeRename}
title="Rename workspace"
footer={(
<>
<Button variant="outline" disabled={renaming} onClick={closeRename}>Cancel</Button>
<Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>Rename</Button>
</>
)}
>
<input
className={css.renameInput}
value={renameDraft}
aria-label="Workspace name"
autoFocus
disabled={renaming}
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmRename()
}
}}
/>
{renameDuplicate && (
<div className={css.renameError} role="alert">A workspace named “{renameTrimmed}” already exists.</div>
)}
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
</Modal>
</div>
)
}

View File

@@ -1,9 +1,15 @@
/** Shared Workspace picker for the sidebar and New Session hero. */
/**
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
* package) and wrapped by WorkspacePicker for the conversation empty-state
* slot registration.
*/
import type { RefObject } from 'react'
import { useCallback, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
@@ -13,14 +19,35 @@ const CREATE_NEW = '::create-new'
type ModalKind = 'path' | 'create' | null
export function WorkspacePicker({
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
/** Popover visibility (anchor button toggle state, owner-local). */
open: boolean
/** The anchor button element — the popover's placement anchor. */
anchorRef?: RefObject<HTMLElement | null> | undefined
/** Selector hook over the workspace list (framework standard hook). */
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
}
/**
* Render the pick menu plus the two create dialogs.
* @param props - owner-controlled flow props.
* @returns menu + dialog elements.
*/
export function WorkspaceCreateFlow({
open,
anchorRef,
useWorkspaces,
createWorkspace,
onPick,
onClose,
createWorkspace,
}: WorkspacePickerProps) {
}: WorkspaceCreateFlowProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const getAnchorRect = useCallback(
@@ -194,3 +221,29 @@ export function WorkspacePicker({
</>
)
}
/**
* The conversation empty-state registration: adapts the owner share to the
* core flow (all state and semantics live in the flow / the owner).
* @param props - empty-state slot props (owner share + injected creation callback).
* @returns the flow element.
*/
export function WorkspacePicker({
open,
anchorRef,
useWorkspaces,
onPick,
onClose,
createWorkspace,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
open={open}
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
onPick={onPick}
onClose={onClose}
/>
)
}

View File

@@ -1,30 +1,59 @@
/**
* Shared Workspace picker contract for the sidebar and page-local Session Intent hero
* slots. Each runtime share provides its owner's popover controls plus the
* global useWorkspaces hook; this package adds the injected Host Workspace
* creation callback.
* ui-workspace contracts. Two registrations share this package:
*
* - WorkspaceBrowser fills the sidebar shell's `sidebar.workspaces` hole —
* the whole browsing region (section header, search, grouped/flat session
* list, workspace dialogs). It registers this package's viewing store and
* consumes the shell's two-fact owner share (wide / expandSidebar).
* - WorkspacePicker fills the conversation empty-state hole (menu +
* create dialogs shared with the browser).
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull both owner SlotMap merges into programs that resolve the
// picker runtime union below.
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull the owner SlotMap merges into programs that resolve the
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
* Registrant-private injected share. Pick semantics remain in each owner's
* onPick callback; this callback creates only the real Host Workspace. A type
* alias supplies the implicit index signature required by the registry.
* Browser-private injected share (arrives via the register inject factory).
* Data reads use the global framework hooks; these are the Host actions the
* browsing region drives.
*/
export type WorkspaceBrowserInjected = {
/** Start or replace the current frontend Session Intent. */
startSession: (workspaceId?: WorkspaceId, prompt?: string) => void
/** Open a real Session. */
open: (sessionId: SessionId) => void
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/**
* Reorder a session inside its Workspace account (DOM-insertBefore
* semantics: omitted anchor appends to the end). The view refreshes from
* the Host response/changed frame; failures leave the order unchanged.
*/
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
export type WorkspaceBrowserProps =
PropsRuntime<'sidebar.workspaces'>
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
& WorkspaceBrowserInjected
/**
* Picker-private injected share. Pick semantics remain in the owner's onPick
* callback; this callback creates only the real Host Workspace. A type alias
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
}
/**
* Full picker props: either owner's runtime share, including useWorkspaces,
* plus this package's injected creation callback.
*/
/** Full picker props: the empty-state owner share plus the creation callback. */
export type WorkspacePickerProps =
(PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>)
& WorkspacePickerInjected
PropsRuntime<'conversation.empty.workspace'> & WorkspacePickerInjected

View File

@@ -1,54 +1,85 @@
/**
* Shared Workspace picker plugin, browser half. WorkspacePicker registers in
* the sidebar and page-local Session Intent hero slots, reads real Host Workspaces
* through the global useWorkspaces hook, and delegates selection semantics to
* each owner. Its injected share creates a Workspace without creating a
* Session. Export discipline: packages/client/AGENTS.md.
* Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
* and WorkspacePicker fills the conversation empty-state hole. Both read real
* Host Workspaces through the global useWorkspaces hook. Export discipline:
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerInjected } from './contract/slots.ts'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { createWorkspaceViewStore } from './stores.ts'
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts'
export type {
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
} from './contract/slots.ts'
/**
* Required services (cordis fiber inject). The target slot is declared by
* the ui-sidebar apply, whose activation order relative to this one is NOT
* constrained: dshClient.inject edges are informational (loading/prefetch
* metadata, never apply sequencing) and the sidebar provides no waitable
* service. apply therefore registers via declaration-aware deferral instead
* of assuming order.
* Required services (cordis fiber inject). The target slots are declared by
* the ui-sidebar / ui-conversation applies, whose activation order relative
* to this one is NOT constrained: dshClient.inject edges are informational
* (loading/prefetch metadata, never apply sequencing) and neither owner
* provides a waitable service. apply therefore registers via
* declaration-aware deferral instead of assuming order.
*/
export const inject = ['slots', 'workspaces']
export const inject = ['slots', 'sessions', 'workspaces']
/**
* Register WorkspacePicker in both owner slots once their declarations are on
* the ledger. The inject factory returns a plain Workspace creation callback;
* data reads use the framework's global useWorkspaces hook.
* Register the browser and picker once their slot declarations are on the
* ledger. Inject factories return plain callbacks; data reads use the
* framework's global hooks.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const injected = (): WorkspacePickerInjected => ({
const browserInjected = (): WorkspaceBrowserInjected => ({
startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
})
// Declaration-aware registration: the sidebar's declaring apply may
// activate after this one (entry activation order is unconstrained), and a
// register into an undeclared slot throws. Register once the declaration
// is on the ledger; the subscription also re-registers after an HMR
// collapse re-declares the slot (the cascade disposed our entry with it).
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register
// into an undeclared slot throws. Register once the declaration is on the
// ledger; the subscription also re-registers after an HMR collapse
// re-declares the slot (the cascade disposed our entry with it).
ctx.effect(() => {
const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const
const disposers = new Map<(typeof slotNames)[number], () => void>()
const tryRegister = (name: (typeof slotNames)[number]): void => {
if (ctx.slots.spec(name) === undefined) return
if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return
disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker))
const registrations = [
{
name: 'sidebar.workspaces' as const,
component: WorkspaceBrowser,
register: () => ctx.slots.register(
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
WorkspaceBrowser,
),
},
{
name: 'conversation.empty.workspace' as const,
component: WorkspacePicker,
register: () => ctx.slots.register(
{ name: 'conversation.empty.workspace', inject: pickerInjected },
WorkspacePicker,
),
},
]
const disposers = new Map<string, () => void>()
const tryRegister = (entry: (typeof registrations)[number]): void => {
if (ctx.slots.spec(entry.name) === undefined) return
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
disposers.set(entry.name, entry.register())
}
const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) }))
for (const name of slotNames) tryRegister(name)
const unsubscribers = registrations.map(entry =>
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
for (const entry of registrations) tryRegister(entry)
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
for (const dispose of disposers.values()) dispose()
}
}, 'ui-workspace: picker registrations')
}, 'ui-workspace: browser + picker registrations')
}

View File

@@ -0,0 +1,265 @@
/* 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,
.projectRow.menuOpen .rowActions,
.sessionRow.menuOpen .rowActions {
display: inline-flex;
}
.sessionRow:hover .time,
.sessionRow.menuOpen .time {
display: none;
}
/* An open row menu pins the hover affordances (figma: the row keeps its
hover fill while its dropdown is up). */
.projectRow.menuOpen,
.sessionRow.menuOpen {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Drag reorder insert line (workspace-group roots): 2px accent above or
below the hovered row, drawn with box-shadow so no layout shift. */
.sessionRow.dropBefore {
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
}
.sessionRow.dropAfter {
box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary);
}
/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
.hoverContent {
display: flex;
flex-direction: column;
gap: 8px;
}
.hoverTitle {
font-size: 14px;
line-height: 20px;
color: #FFFFFF;
overflow-wrap: break-word;
}
.hoverTime {
font-size: 12px;
line-height: 16px;
color: #CFD3D6;
}
.hoverStatus {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
line-height: 20px;
color: #ADB2B8;
}
.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;
}
}

View File

@@ -0,0 +1,284 @@
/**
* Workspace browser 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. Row ... menus are visual-only
* except workspace Rename; the session hover card is suppressed while a menu
* is open.
*/
import { useState } from 'react'
import clsx from 'clsx'
import {
HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16,
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTrashOutline16, IconTriangleRightFill14, Menu, 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
const SESSION_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
{ id: 'delete', label: 'Delete session', icon: <IconTrashOutline16 />, danger: true },
]
const WORKSPACE_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true },
]
/**
* 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, onRename }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
/** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */
onRename?: (() => void) | undefined
}) {
const row = group
const active = group.expanded && group.containsCurrent
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
const [menuOpen, setMenuOpen] = useState(false)
return (
<div
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
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}>
{onRename !== undefined && (
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={WORKSPACE_MENU_ITEMS}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename()
// Delete is visual-only for now.
}}
portal
closeOnPointerLeave
anchor={(
<button
type="button"
className={css.iconButton}
aria-label={`Workspace actions for ${row.label}`}
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
>
<IconEllipsisOutline16 />
</button>
)}
/>
)}
<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.
* One status-slot indent in both grouped and flat lists (session rows carry
* no twist slot either, so titles align).
* @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.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.
*/
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{node.title}</div>
<div className={css.hoverTime}>{`${formatRelativeTime(node.updatedAt, now)} ago`}</div>
<div className={css.hoverStatus}>
<StateDot state={node.running ? 'ongoing' : 'done'} />
<span>{node.running ? 'Running' : 'Idle'}</span>
</div>
</div>
)
}
/**
* Root-row drag wiring supplied by the group owner (workspace groups only).
* `drop` reports the half of the row the pointer released on: 'before'
* inserts above this row, 'after' below it (the owner resolves the anchor).
*/
export interface RowDragProps {
/** Start dragging this row. */
start: () => void
/** A drag from the same group is in flight (rows show insert markers). */
active: boolean
/** Current marker on this row: insert line above, below, or none. */
marker: 'before' | 'after' | null
/** Report the hovered half while a same-group drag passes over this row. */
hover: (half: 'before' | 'after') => void
drop: (half: 'before' | 'after') => void
end: () => void
}
/** Pointer-position half of a row (insert line above or below). */
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
const rect = e.currentTarget.getBoundingClientRect()
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: {
node: SessionNode
depth: number
currentId: string | undefined
now: number
onOpen: (id: SessionNode['id']) => void
onToggle: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group roots outside search). */
drag?: RowDragProps | undefined
/** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */
flat?: boolean
}) {
const row = node
const selected = node.id === currentId
const [menuOpen, setMenuOpen] = useState(false)
// 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, menuOpen && css.menuOpen,
drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter,
)}
role="treeitem"
aria-selected={selected}
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
onClick={() => { onOpen(node.id) }}
draggable={drag !== undefined}
onDragStart={drag === undefined
? undefined
: (e) => {
e.dataTransfer.effectAllowed = 'move'
drag.start()
}}
onDragEnd={drag?.end}
onDragOver={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
drag.hover(rowHalf(e))
}}
onDrop={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
drag.drop(rowHalf(e))
}}
>
{row.hasChildren && !flat
? (
<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>
)
: null}
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
<span className={css.rowActions}>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={SESSION_MENU_ITEMS}
onSelect={() => { setMenuOpen(false) }} // Visual-only for now.
portal
closeOnPointerLeave
anchor={(
<button
type="button"
className={css.iconButton}
aria-label={`Session actions for ${row.title}`}
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
>
<IconEllipsisOutline16 />
</button>
)}
/>
</span>
</div>
)
return (
<>
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} />}
disabled={menuOpen || drag?.active === true}
/>
{node.children.map(child => (
<SessionNodeItem
key={child.id}
node={child}
depth={depth + 1}
currentId={currentId}
now={now}
onOpen={onOpen}
onToggle={onToggle}
/>
))}
</>
)
}

View File

@@ -0,0 +1,36 @@
/**
* The workspace browser's viewing store: the session-list grouping mode,
* persisted across reloads. Module level exports the factory only (a
* module-level handle would pin the store identity across plugin reloads);
* register() receives the factory and the browser derives its PropsStore
* share from the return type.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
/** Session-list grouping mode: workspace sections or one flat recency list. */
export type WorkspaceGroupBy = 'workspace' | 'flat'
/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */
type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
/**
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
type WorkspaceViewActions = {
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
}
/**
* Create the workspace browser viewing store handle.
* @returns the store handle (spec + type + identity + factory in one).
*/
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
return defineStore({
init: (): WorkspaceViewState => ({ groupBy: 'workspace' }),
persist: 'dsh.workspace.view',
actions: {
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
},
})
}

View File

@@ -0,0 +1,340 @@
/**
* Derives the workspace browser 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 workspace browser 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` (rendered only while the
* group is expanded; expansion stays viewer-owned). 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 === '') {
// The intent never forces expansion — the viewer auto-expands the
// target group once (current-group effect); the toggle stays live.
const expanded = 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
}
/**
* Derive the flat session list ("In one list" mode): every session — fork
* children included — as a top-level row, strictly newest-first. No grouping,
* no parent/child adjacency; rows reuse SessionNode with children always
* empty so the renderer stays branch-free. Search mode filters by
* case-insensitive display-title substring.
* @param list - sessions list snapshot.
* @param view - the search query (expansion state does not apply).
* @returns flat rows in render order.
*/
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
const q = view.query.trim().toLowerCase()
const rows: SessionSummary[] = []
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined) continue
if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue
rows.push(s)
}
rows.sort(byRecency)
return rows.map(s => sessionNode(s, [], false, false))
}
/**
* 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`
}

View File

@@ -2,7 +2,8 @@ import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
async function bench() {
@@ -13,32 +14,33 @@ async function bench() {
path: 'name' in input ? `/projects/${input.name}` : input.path,
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
ctx.provide('workspaces', { create })
return { ctx, slots: ctx.get('slots') as SlotsService, create }
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never)
ctx.provide('sessions', { open } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open }
}
function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void {
return slots.register(
{ name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never,
() => null,
)
}
type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace'
function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected {
const entry = slots.entries(name)[0]!
return (entry.inject as () => WorkspacePickerInjected)()
/** Declare one or both holes with a single root registration ('root' is a single slot). */
function declare(slots: SlotsService, ...names: HoleName[]): () => void {
const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }]))
return slots.register({ name: 'root', children } as never, () => null)
}
describe('ui-workspace apply', () => {
it('declares the independent Workspace service', () => {
expect(inject).toEqual(['slots', 'workspaces'])
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
})
it('registers the shared picker for declarations that arrive before or after apply', async () => {
it('registers browser and picker for declarations arriving before or after apply', async () => {
const before = await bench()
declare(before.slots, 'sidebar.workspace')
declare(before.slots, 'sidebar.workspaces')
await before.ctx.plugin({ inject: [...inject], apply }).await()
expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker)
expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser)
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
@@ -47,23 +49,35 @@ describe('ui-workspace apply', () => {
expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
})
it('routes name and path creation to WorkspacesService', async () => {
it('routes browser actions and picker creation to the services', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspace')
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(b.slots, 'sidebar.workspace')
await injected.createWorkspace({ name: 'project' })
await injected.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' })
expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' })
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
browser.startSession('ws', 'prompt')
expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt')
browser.open('session')
expect(b.open).toHaveBeenCalledWith('session')
await browser.renameWorkspace('ws', 'renamed')
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
await browser.insertSessionBefore('ws', 's1', 's2')
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
await browser.createWorkspace({ name: 'project' })
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)()
await picker.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
})
it('unregisters picker entries on teardown', async () => {
it('unregisters both entries on teardown', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspace')
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await fiber.dispose()
expect(b.slots.entries('sidebar.workspace')).toHaveLength(0)
expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0)
expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,251 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/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
/** Half detection reads the row rect; jsdom rects are all-zero by default. */
function stubRect(row: HTMLElement): void {
row.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
x: 0, y: 100, toJSON: () => ({}),
} as DOMRect)
}
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
return {
start: vi.fn(), active: false, marker: null,
hover: vi.fn(), drop: vi.fn(), end: vi.fn(),
...overrides,
}
}
const dataTransfer = { effectAllowed: '', dropEffect: '' }
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
Object.defineProperty(event, 'clientY', { value: clientY })
Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } })
fireEvent(row, event)
}
describe('workspace browser 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')
})
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
const onRename = vi.fn()
const onToggle = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />)
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
// Opening the menu neither toggles the group nor renames yet.
expect(onToggle).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/)
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
expect(onRename).toHaveBeenCalledOnce()
expect(screen.queryByRole('menu')).toBeNull()
// Delete stays visual-only: selecting it just closes the menu.
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledOnce()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})
it('ungrouped bucket renders no workspace menu', () => {
const group: GroupNode = {
key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
})
it('session row menu opens without opening the session and closes on selection', () => {
const onOpen = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
expect(onOpen).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(onOpen).not.toHaveBeenCalled()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})
it('flat variant renders no twist even for a parent and ignores toggling', () => {
const node: SessionNode = {
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />)
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
})
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
expanded: false, running: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
// Card body: full title + relative time + running status.
expect(screen.getAllByText('Hovered')).toHaveLength(2)
expect(screen.getByText('1min ago')).toBeTruthy()
expect(screen.getByText('Running')).toBeTruthy()
fireEvent.pointerLeave(wrapper)
// Menu open (disabled=true) suppresses the card for the same hover.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' }))
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('1min ago')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('idle hover card shows the Idle status line', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('Idle')).toBeTruthy()
expect(screen.getByText('now ago')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
)
const row = screen.getByRole('treeitem')
stubRect(row)
expect(row.getAttribute('draggable')).toBe('true')
fireEvent.dragStart(row, { dataTransfer })
expect(inactive.start).toHaveBeenCalledOnce()
// Inactive drag: hover and drop are rejected.
fireEvent.dragOver(row, { dataTransfer })
fireEvent.drop(row, { dataTransfer })
expect(inactive.hover).not.toHaveBeenCalled()
expect(inactive.drop).not.toHaveBeenCalled()
fireEvent.dragEnd(row)
expect(inactive.end).toHaveBeenCalledOnce()
const active = dragProps({ active: true, marker: 'before' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
)
stubRect(screen.getByRole('treeitem'))
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
fireDrag(screen.getByRole('treeitem'), 'dragOver', 105)
expect(active.hover).toHaveBeenCalledWith('before')
fireDrag(screen.getByRole('treeitem'), 'dragOver', 130)
expect(active.hover).toHaveBeenCalledWith('after')
fireDrag(screen.getByRole('treeitem'), 'drop', 130)
expect(active.drop).toHaveBeenCalledWith('after')
const after = dragProps({ active: true, marker: 'after' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})
})

View File

@@ -0,0 +1,198 @@
import { describe, expect, it } from 'vitest'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
import { createWorkspaceViewStore } from '../src/client/stores.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('an Intent no longer forces its target group expanded (viewer owns expansion)', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view())
expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: 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('deriveFlat', () => {
it('flattens every session — fork children included — newest-first with id tiebreak', () => {
const parent = summary('parent', 10)
const child = { ...summary('child', 30), parentId: parent.id }
const tieB = summary('tie-b', 20)
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
// Rows are branch-free: no children, no expansion.
expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
})
it('search filters by case-insensitive display-title substring', () => {
const hit = { ...summary('hit', 2), displayTitle: 'Needle row' }
const miss = { ...summary('miss', 1), displayTitle: 'Other' }
expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')])
})
it('tolerates ids whose summary has not landed yet', () => {
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
})
})
describe('createWorkspaceViewStore', () => {
it('defaults to workspace grouping; setGroupBy is the sole mutation', () => {
const store = createWorkspaceViewStore().create()
expect(store.getSnapshot().groupBy).toBe('workspace')
store.actions.setGroupBy('flat')
expect(store.getSnapshot().groupBy).toBe('flat')
})
})
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')
})
})

View File

@@ -0,0 +1,458 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, updatedAt, ...overrides,
})
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
intent: undefined,
...overrides,
})
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
Object.defineProperty(event, 'clientY', { value: clientY })
Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } })
fireEvent(row, event)
}
function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
const store = createWorkspaceViewStore().create()
const props: WorkspaceBrowserProps = {
wide: true,
expandSidebar: vi.fn(),
useSessions: hook(sessionState([])),
useWorkspaces: hook(workspaceState([])),
useStore: bindSnapshotSelector(store),
actions: store.actions,
startSession: vi.fn(),
open: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)
return { view, props, store }
}
/** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */
function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrowserProps>) {
Object.assign(b.props, overrides)
b.view.rerender(<WorkspaceBrowser {...b.props} />)
}
describe('WorkspaceBrowser', () => {
it('renders the grouped tree by default and switches to the flat list via Group by', () => {
const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)])
const b = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])),
})
expect(screen.getByText('Workspaces')).toBeTruthy()
expect(screen.getByText('alpha')).toBeTruthy()
// Sessions hidden while their group is folded.
expect(screen.queryByText('alpha-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label
fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' }))
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
expect(b.store.getSnapshot().groupBy).toBe('flat')
expect(screen.getByText('Sessions')).toBeTruthy()
expect(screen.queryByText('alpha')).toBeNull()
expect(screen.getByText('alpha-s')).toBeTruthy()
expect(screen.getByText('beta-s')).toBeTruthy()
// Back to workspace grouping through the same menu.
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' }))
expect(b.store.getSnapshot().groupBy).toBe('workspace')
expect(screen.getByText('Workspaces')).toBeTruthy()
// Escape closes the menu without picking.
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(b.store.getSnapshot().groupBy).toBe('workspace')
})
it('expands a group on click and opens a session row', () => {
const open = vi.fn()
mount({
useSessions: hook(sessionState([summary('alpha-s', 1)])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
open,
})
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByText('alpha-s'))
expect(open).toHaveBeenCalledWith(sid('alpha-s'))
// Collapse hides the row again.
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('alpha-s')).toBeNull()
})
it('unfolds a session subtree through the row twist', () => {
const parent = summary('parent-s', 2)
const child = { ...summary('child-s', 1), parentId: parent.id }
mount({
useSessions: hook(sessionState([parent, child])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
})
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('child-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('child-s')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('child-s')).toBeNull()
})
it('auto-expands the selected session group and starts a session from the group +', () => {
const startSession = vi.fn()
mount({
useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })),
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
startSession,
})
// The current-group effect expanded the owning group without a click.
expect(screen.getByText('alpha-s')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' }))
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
})
it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => {
const startSession = vi.fn()
mount({
useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })),
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
startSession,
})
// The loose session's group is UNGROUPED_KEY: expanded by the effect.
expect(screen.getByText('loose')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
expect(startSession).not.toHaveBeenCalled()
})
it('keeps an already-expanded group when the selection moves within it', () => {
const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') })
const b = mount({
useSessions: hook(first),
useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])),
})
expect(screen.getByText('a')).toBeTruthy()
// Selection hop inside the same group: the effect re-runs and leaves the
// expansion list unchanged (no duplicate key, group still open).
rerender(b, { useSessions: hook({ ...first, current: sid('b') }) })
expect(screen.getByText('b')).toBeTruthy()
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('b')).toBeNull()
})
it('renders the intent placeholder in both modes', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const }
const sessions = sessionState([], { intent, current: sid('intent') })
const b = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
})
// Grouped: the current-group effect expands the target group.
expect(screen.getByText('New session')).toBeTruthy()
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('New session')).toBeTruthy()
})
it('searches across groups, clears via the clear button, and shows the empty states', () => {
const sessions = sessionState([
summary('needle-row', 2, { displayTitle: 'Needle row' }),
summary('other-row', 1, { displayTitle: 'Other row' }),
])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
})
const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...')
fireEvent.change(input, { target: { value: 'needle' } })
// Search forces matches visible without expansion state.
expect(screen.getByText('Needle row')).toBeTruthy()
expect(screen.queryByText('Other row')).toBeNull()
fireEvent.change(input, { target: { value: 'zzz' } })
expect(screen.getByText('No matches')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
expect(input.value).toBe('')
// Clicking the field row focuses the input (wide mode).
fireEvent.click(input.parentElement as HTMLElement)
expect(document.activeElement).toBe(input)
})
it('shows the no-sessions empty state in both modes', () => {
const b = mount()
expect(screen.getByText('No sessions yet')).toBeTruthy()
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('No sessions yet')).toBeTruthy()
// Flat search misses show No matches.
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } })
expect(screen.getByText('No matches')).toBeTruthy()
})
it('rail state renders icon controls that request expansion', () => {
vi.useFakeTimers()
try {
const expandSidebar = vi.fn()
const b = mount({ wide: false, expandSidebar })
// No wide chrome in rail state.
expect(screen.queryByText('Workspaces')).toBeNull()
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
// The wide flip mounts the input and focuses it after the slide.
rerender(b, { wide: true })
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { vi.advanceTimersByTime(300) })
expect(document.activeElement).toBe(input)
// Wide search button is decorative (tabIndex -1, no expand call).
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
const expandSidebar = vi.fn()
const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
rerender(b, { wide: true })
// The picker menu is open (anchored on the +); picking starts a session.
fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
expect(screen.queryByRole('menu')).toBeNull()
// Wide toggle: open and close without expand requests.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(expandSidebar).toHaveBeenCalledTimes(1)
// Escape closes the picker through its own onClose.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})
it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
const insertSessionBefore = vi.fn(async () => {})
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
insertSessionBefore,
})
fireEvent.click(screen.getByText('alpha'))
const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
three.getBoundingClientRect = () => ({
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
} as DOMRect)
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
// Drop on the top half of "three": insert one before three.
fireDrag(three, 'dragOver', 205)
fireDrag(three, 'drop', 205)
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three'))
// Dropping right back onto its own position is a no-op — top half
// (anchor = itself) and bottom half (anchor = the next root) alike.
fireEvent.dragStart(one, { dataTransfer })
one.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
} as DOMRect)
fireDrag(one, 'dragOver', 105)
fireDrag(one, 'drop', 105)
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
fireEvent.dragStart(one, { dataTransfer })
fireDrag(one, 'drop', 130)
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
})
it('still sends the reorder when the dragged row left the group mid-drag', () => {
const insertSessionBefore = vi.fn(async () => {})
const sessions = sessionState([summary('one', 2), summary('two', 1)])
const b = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
insertSessionBefore,
})
fireEvent.click(screen.getByText('alpha'))
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } })
// The host dropped "one" from the workspace account while the drag is in
// flight: the source index is gone but the drop still resolves its anchor.
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) })
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
fireDrag(two, 'drop', 155)
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
})
it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => {
const insertSessionBefore = vi.fn(async () => {})
const sessions = sessionState([summary('one', 2), summary('two', 1)])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
insertSessionBefore,
})
fireEvent.click(screen.getByText('alpha'))
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
fireEvent.dragEnd(one)
// The drag ended: rows no longer accept drops.
fireDrag(two, 'drop', 180)
expect(insertSessionBefore).not.toHaveBeenCalled()
// Bottom half of the last row: append (anchor omitted).
fireEvent.dragStart(one, { dataTransfer })
fireDrag(two, 'dragOver', 180)
fireDrag(two, 'drop', 180)
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
})
it('logs and keeps the order when the reorder call rejects', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') })
const sessions = sessionState([summary('one', 2), summary('two', 1)])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
insertSessionBefore,
})
fireEvent.click(screen.getByText('alpha'))
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
fireDrag(two, 'drop', 180)
await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
} finally {
warn.mockRestore()
}
})
it('renames a workspace through the row menu dialog', async () => {
let resolveRename!: () => void
const renameWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveRename = resolve }))
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])),
renameWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
expect(input.value).toBe('Alpha')
// Unchanged and blank names stay blocked.
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.change(input, { target: { value: ' ' } })
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
// A duplicate of another workspace's title shows the inline conflict.
fireEvent.change(input, { target: { value: ' Beta ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.change(input, { target: { value: 'Gamma' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
// While renaming: input disabled, close blocked, Enter ignored.
expect(input.disabled).toBe(true)
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByRole('dialog')).toBeTruthy()
await act(async () => { resolveRename() })
expect(screen.queryByRole('dialog')).toBeNull()
})
it('rename via Enter, failure surfaces the error, Cancel closes', async () => {
const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') })
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
renameWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
// Enter with a blocked draft (unchanged) does nothing.
fireEvent.keyDown(input, { key: 'Enter' })
expect(renameWorkspace).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: 'Renamed' } })
fireEvent.keyDown(input, { key: 'a' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed')
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') })
// The dialog stays for retry; typing clears the error; Cancel closes.
fireEvent.change(input, { target: { value: 'Renamed2' } })
expect(screen.queryByRole('alert')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('reports non-Error rename failures as text', async () => {
const renameWorkspace = vi.fn(async () => { throw 'denied' })
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
renameWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
})
it('search hides drag affordances (rows are not draggable during search)', () => {
const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
})
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } })
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
expect(row.getAttribute('draggable')).toBe('false')
})
})