/** * Workspace browser tree row components: pure presentational. * Fully aligned with DSH 0.1.7-rc.1 while providing hierarchical fork-tree * indentation, branch chevrons, branch descendant count badges, and slot-rendered actions. */ import { useEffect, useRef, useState, useMemo } from 'react' import clsx from 'clsx' import { HoverCard, IconAlarmClockOutlineRegular, IconBranchOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconEditOutlineRegular, IconEllipsisOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, IconNewChatOutlineRegular, IconPinFillRegular, IconPlusOutlineRegular, IconTrashOutlineRegular, IconTreeCornerRegular, IconTriangleRightFillRegular, IconUnarchiveOutlineRegular, Menu, Tooltip, relativeTime, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import { countDescendants, aggregateDescendantStatus, type BranchNode } from '../branch.ts' import { uiLabel } from '../ui-label.ts' import css from './Rows.module.css' const MIN_TITLE_REVEAL_PX = 8 const TITLE_MARQUEE_PX_PER_MS = 0.03 function placeTitle(title: HTMLElement, left: number, range: number) { if (typeof title.scrollTo === 'function') { title.scrollTo({ left, behavior: 'instant' as ScrollBehavior }) } else { title.scrollLeft = left } if (left > 0) title.dataset.scrolled = '' else delete title.dataset.scrolled if (left < range) title.dataset.clipped = '' else delete title.dataset.clipped } function restTitle(title: HTMLElement) { if (typeof title.scrollTo === 'function') { title.scrollTo({ left: 0, behavior: 'instant' as ScrollBehavior }) } else { title.scrollLeft = 0 } delete title.dataset.scrolled delete title.dataset.clipped } function useTitleMarquee(titleRef: React.RefObject) { const frame = useRef(0) useEffect(() => { return () => { cancelAnimationFrame(frame.current) } }, []) return useMemo(() => ({ enter: () => { if (titleRef.current === null) return const element = titleRef.current const range = element.scrollWidth - element.clientWidth if (range <= MIN_TITLE_REVEAL_PX) return if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) { placeTitle(element, range, range) return } cancelAnimationFrame(frame.current) let previous: number | undefined let position = 0 const step = (now: number) => { position += previous === undefined ? 0 : (now - previous) * TITLE_MARQUEE_PX_PER_MS previous = now placeTitle(element, Math.min(position, range), range) if (position < range) { frame.current = requestAnimationFrame(step) } } frame.current = requestAnimationFrame(step) }, leave: () => { cancelAnimationFrame(frame.current) if (titleRef.current === null) return restTitle(titleRef.current) }, }), [titleRef]) } /** The standard locale seat, prop-passed from the browser root. */ type RowTranslate = WorkspaceBrowserProps['t'] /** Row display title: blank rows show the localized New Session label. */ export function displayTitle(node: SessionNode, t: RowTranslate): string { return node.blank ? t('session.new') : node.title } /** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */ export function timeLabel(updatedAt: number, now: number, t: RowTranslate): string { const { unit, n } = relativeTime(updatedAt, now) return unit === 'now' ? t('time.now') : t(`time.${unit}`, { n }) } /** Hover-card variant: distances wrap in the ago template; the now bucket stays bare. */ function hoverTimeLabel(updatedAt: number, now: number, t: RowTranslate): string { const { unit, n } = relativeTime(updatedAt, now) return unit === 'now' ? t('time.now') : t('time.ago', { t: t(`time.${unit}`, { n }) }) } /** Absolute creation time through the dictionary's date template. */ function createdLabel(createdAt: number, t: RowTranslate): string { const d = new Date(createdAt) const pad2 = (v: number): string => String(v).padStart(2, '0') const date = t('date.ymd', { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() }) return t('hover.created', { time: `${date} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` }) } /** Hover-card body: workspace title, display directory path, absolute creation time. */ function WorkspaceHoverContent({ label, cwd, createdAt, t, }: { label: string cwd: string | undefined createdAt: number t: RowTranslate }) { return (
{label}
{cwd}
{createdLabel(createdAt, t)}
) } /** * Row drag wiring supplied by the tree owner. */ export interface RowDragProps { start: () => void active: boolean marker: 'before' | 'after' | null hover: (half: 'before' | 'after') => void drop: (half: 'before' | 'after') => void end: () => void } interface WorkspaceRowDragProps { start: () => void end: () => void } export function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { const rect = e.currentTarget.getBoundingClientRect() return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } /** * Project (workspace) header row: folder + title. */ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, newShortcut, t, currentCollapsed, containsCurrentDescendant, }: { group: GroupNode onToggle: () => void onCreate: () => void actions?: { rename: () => void; delete: () => void } | undefined drag?: WorkspaceRowDragProps | undefined home?: string | undefined newShortcut?: any t: RowTranslate currentCollapsed?: boolean | undefined containsCurrentDescendant?: boolean | undefined }) { const row = group const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label const active = (group.expanded && group.containsCurrent) || (!group.expanded && (currentCollapsed || containsCurrentDescendant)) const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ { id: 'rename', label: t('rename'), icon: }, { id: 'delete', label: t('delete.workspace'), icon: , danger: true }, ] const ownRow = (
{ e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', row.key) drag.start() } } onDragEnd={drag?.end} > {row.expanded ? : } {label} {actions !== undefined && ( { setMenuOpen(false) }} items={workspaceMenuItems} onSelect={(id) => { setMenuOpen(false) if (id === 'rename') actions.rename() else if (id === 'delete') actions.delete() }} portal closeOnPointerLeave anchor={ } /> )}
) if (row.createdAt === undefined) return ownRow return ( } disabled={menuOpen} copyText={row.cwd} copyLabel={t('copy')} copiedLabel={t('hover.copied')} /> ) } function assertNever(value: never): never { throw new Error(`unknown pending interaction: ${String(value)}`) } interface SessionStatus { state: StateDotState label: string trailingLabel?: string } function sessionStatuses( node: Pick< SessionNode, 'pendingInteraction' | 'running' | 'runningSubagentCount' | 'completed' >, t: RowTranslate, ): readonly [SessionStatus, ...SessionStatus[]] { const subagents: SessionStatus | undefined = node.runningSubagentCount === 0 ? undefined : { state: 'ongoing', label: t( node.runningSubagentCount === 1 ? 'status.subagentsRunning.one' : 'status.subagentsRunning.other', { n: node.runningSubagentCount }, ), } let pending: SessionStatus | undefined switch (node.pendingInteraction) { case 'approval': pending = { state: 'warning', label: t('status.waitingApproval'), trailingLabel: t('status.waitingApproval'), } break case 'plan-review': pending = { state: 'warning', label: t('status.planReview'), trailingLabel: t('status.planReview'), } break case 'question': pending = { state: 'warning', label: t('status.waitingAnswer'), trailingLabel: t('status.waitingAnswer'), } break case undefined: break default: return assertNever(node.pendingInteraction) } if (pending !== undefined) return subagents === undefined ? [pending] : [pending, subagents] if (node.running) { const primary: SessionStatus = { state: 'ongoing', label: t('status.running') } return subagents === undefined ? [primary] : [primary, subagents] } if (subagents !== undefined) return [subagents] if (node.completed) return [{ state: 'done', label: t('status.completed') }] return [{ state: 'idle', label: t('status.idle') }] } function SessionStatusDots({ statuses, }: { statuses: readonly [SessionStatus, ...SessionStatus[]] }) { return ( <> {statuses.map((status) => ( {status.label} ))} ) } function ActiveScheduleIndicator({ t, search = false, }: { t: RowTranslate search?: boolean }) { const label = t('schedule.active') return ( ) } function PinnedIndicator({ t }: { t: RowTranslate }) { const label = uiLabel('已置顶', 'Pinned') return ( ) } function SessionHoverContent({ node, now, t, path = [], renderSlot, }: { node: SessionNode now: number t: RowTranslate path?: readonly string[] renderSlot?: any }) { const statuses = sessionStatuses(node, t).filter( (status) => !(node.archived && (status.state === 'done' || status.state === 'idle')) ) const pathLabel = path.length > 0 ? path.join(' / ') : undefined return (
{pathLabel !== undefined && (
{pathLabel}
)}
{displayTitle(node, t)}
{!node.blank && (
{hoverTimeLabel(node.updatedAt, now, t)}
)} {renderSlot?.('sidebar.session.row.hover', { sessionId: node.id })} {statuses.map((status) => (
{status.label}
))}
) } export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t, }: { result: SearchResultNode currentId: string | undefined onOpen: (id: SearchResultNode['id']) => void onUnarchive?: (id: SearchResultNode['id']) => void t: RowTranslate }) { const selected = result.id === currentId const statuses = sessionStatuses(result, t) const primaryStatus = statuses[0] return (
{ onOpen(result.id) }} > {!result.archived && (primaryStatus.state !== 'idle' || result.completed) && ( )} {result.title} {result.hasActiveSchedule && } {result.archived && onUnarchive !== undefined && ( )} {result.workspace || t('group.ungrouped')} {result.snippet !== undefined && ( {result.snippet} )}
) } /** * SessionNodeItem: renders one session row with branch indentation, * fold/unfold chevron, quiet badge, and slot-composed actions. */ export function SessionNodeItem({ node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, flat = false, depth = 0, collapsed = false, onToggleCollapse, path = [], isCurrentCollapsedAncestor = false, t, }: { node: SessionNode currentId: string | undefined now: number onOpen: (id: SessionNode['id']) => void onRenameRequest?: (id: SessionNode['id'], currentTitle: string) => void renderSlot?: any onReveal?: (() => void) | undefined drag?: RowDragProps | undefined flat?: boolean | undefined depth?: number | undefined collapsed?: boolean | undefined onToggleCollapse?: ((id: SessionNode['id']) => void) | undefined path?: readonly string[] | undefined isCurrentCollapsedAncestor?: boolean | undefined t: RowTranslate }) { const row = node const title = displayTitle(node, t) const selected = node.id === currentId const branchChildren = node.children ?? [] const totalDescendants = countDescendants(node as unknown as BranchNode) const statuses = useMemo(() => { const ownStatuses = sessionStatuses(node, t) const ownActive = ownStatuses[0].state !== 'idle' if (ownActive || !collapsed || branchChildren.length === 0) { return ownStatuses } const descendantStatus = aggregateDescendantStatus(node as unknown as BranchNode) if ( descendantStatus.hasPendingInteraction === undefined && !descendantStatus.hasRunning && !descendantStatus.runningSubagentCount && !descendantStatus.hasCompleted ) { return ownStatuses } const bubbledProxy = { pendingInteraction: descendantStatus.hasPendingInteraction, running: descendantStatus.hasRunning ?? false, runningSubagentCount: descendantStatus.runningSubagentCount ?? 0, completed: descendantStatus.hasCompleted ?? false, } return sessionStatuses(bubbledProxy, t) }, [collapsed, branchChildren.length, node, t]) const primaryStatus = statuses[0] const showStatus = primaryStatus.state !== 'idle' const draggable = drag !== undefined && !row.blank && !row.archived const [menuOpen, setMenuOpen] = useState(false) const [rowHovered, setRowHovered] = useState(false) const menuOpenState = useMemo(() => [menuOpen, setMenuOpen], [menuOpen]) const rowRef = useRef(null) const titleRef = useRef(null) const marquee = useTitleMarquee(titleRef) const hasBranchToggle = branchChildren.length > 0 && onToggleCollapse !== undefined useEffect(() => { if (onReveal === undefined) return rowRef.current?.scrollIntoView({ block: 'nearest' }) onReveal() }, [onReveal]) const ownRow = (
0 && css.branchParent, depth === 0 && branchChildren.length > 0 && css.branchRoot, depth > 0 && css.branchChild, depth >= 2 && css.deepBranch, flat && !showStatus && css.flatSessionRowWithoutStatus, drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter, )} style={ depth > 0 ? ({ '--dsh-branch-indent': `${depth * 16}px`, '--dsh-branch-depth': depth, } as any) : undefined } role="treeitem" aria-level={depth + 1} aria-selected={selected} aria-description={row.archived ? uiLabel('已归档的会话无法直接打开', 'Archived session cannot be opened') : undefined} {...(branchChildren.length > 0 ? { 'aria-expanded': !collapsed } : {})} onClick={() => { onOpen(node.id) }} onPointerEnter={() => { setRowHovered(true) marquee.enter() }} onPointerLeave={() => { setRowHovered(false) marquee.leave() }} draggable={draggable} onDragStart={ !draggable ? undefined : (e) => { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', node.id) drag.start() } } onDragEnd={!draggable ? undefined : 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)) } } > {/* Leading chevron: only for branch parents with children */} {hasBranchToggle ? ( ) : depth > 0 ? ( ) : null} {/* Status dot / Leading slot */} {!row.archived && !row.blank && ( showStatus ? : (renderSlot?.('sidebar.session.row.leading', { sessionId: node.id }) ?? null) )} {/* Title */} { e.stopPropagation() onRenameRequest?.(node.id, row.title) } } > {title} {/* Active schedule indicator */} {row.hasActiveSchedule && } {/* Branch count badge: non-intrusive, auto-hidden on hover so actions have full width */} {branchChildren.length > 0 && ( {totalDescendants} )} {/* Pinned indicator */} {row.pinned && !row.archived && } {/* Time / trailing label */} {!row.blank && ( {primaryStatus.trailingLabel ?? timeLabel(row.updatedAt, now, t)} )} {/* Slot-composed row actions and context menu */} {!row.blank && ( { e.stopPropagation() }} > {renderSlot ? ( <> { setMenuOpen(false) }} portal closeOnPointerLeave items={[]} anchor={ } > {renderSlot( 'sidebar.workspaces.session.menu.item', { sessionId: node.id, displayTitle: row.title, }, { hookContext: menuOpenState }, )} {renderSlot('sidebar.workspaces.session.row.action', { sessionId: node.id, displayTitle: row.title, })} ) : ( { setMenuOpen(false) }} items={[ { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, ]} onSelect={(id) => { setMenuOpen(false) if (id === 'rename') onRenameRequest?.(node.id, row.title) }} portal closeOnPointerLeave anchor={ } /> )} )}
) return ( } openDelayMs={800} disabled={menuOpen || drag?.active === true} copyText={row.blank ? undefined : row.title} copyLabel={t('copy')} copiedLabel={t('hover.copied')} /> ) }