/** * Collapse is a slide plus crossfade: content freezes at its expanded * width (inline style) and fades out in place while the sliding column * (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle * the wide-only content (brand, labels, input, tree) unmounts, dropping * the sessions subscription, and the control rows snap to the 56px rail * (one icon each, same top-down order) fading in as the slide ends. Rail * search expands and focuses the search box. */ import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { BrandWordmark, FishLogo, IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16, IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarRootComponentProps } from './contract/slots.ts' import { deriveRows } from './tree.ts' import { ProjectRowItem, SessionRowItem } from './Rows.tsx' import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ const COLLAPSE_SETTLE_MS = 150 /** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ const EXPAND_SLIDE_MS = 300 const GROUP_BY_ITEMS = [ { id: 'workspace', label: 'WorkSpace' }, // Only workspace grouping is implemented. { id: 'update', label: 'Update', disabled: true }, { id: 'status', label: 'Status', disabled: true }, ] /** Immutable membership toggle for the local expansion arrays. */ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] } /** Group-by strategy menu; own open state so it resets with the wide chrome. */ function GroupByMenu() { const [open, setOpen] = useState(false) return ( { setOpen(false) }} items={GROUP_BY_ITEMS} selectedId="workspace" onSelect={() => { setOpen(false) }} align="end" anchor={( )} /> ) } type SessionTreeProps = Pick & { /** Live search filter owned by the root (the query outlives the tree). */ query: string } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) { const list = useSessions((s) => s) // Selection belongs to the sessions snapshot, not layout state. const current = useSessions((s) => s.current) const [expandedProjects, setExpandedProjects] = useState([]) const [expandedSessions, setExpandedSessions] = useState([]) const rows = useMemo( () => deriveRows(list, { expandedProjects, expandedSessions, query }), [list, expandedProjects, expandedSessions, query], ) const now = Date.now() // Presentational lookup (not tree derivation): the group holding the // selected session gets the active folder; only expanded groups can show it. let activeGroup: string | undefined if (current !== undefined) { for (const row of rows) { if (row.type === 'session' && row.id === current) { activeGroup = row.groupKey; break } } } return (
{rows.length === 0 && (
{query === '' ? 'No sessions yet' : 'No matches'}
)} {rows.map((row, i) => row.type === 'project' ? ( {/* Batch separator: a project row closing an expanded session run (figma 133:7661). */} {i > 0 && rows[i - 1]!.type === 'session' && } { setExpandedProjects((l) => toggled(l, row.key)) }} onCreate={() => { onCreate(row.cwd) }} /> ) : ( { onOpen(row.id) }} onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }} /> ))}
) } /** * Render the sidebar column. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). * @returns the sidebar element tree. */ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { // 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(null) // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. const [settled, setSettled] = useState(collapsed) useEffect(() => { if (!collapsed) { setSettled(false); return } const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS) return () => { window.clearTimeout(timer) } }, [collapsed]) const wide = !collapsed || !settled // Freeze the content at its expanded width while it fades out (collapsed // && wide): the sliding column then clips it instead of reflowing it. The // rail layout (.collapsed styles) only applies once the fade settles. const lastWideWidth = useRef(width) if (!collapsed) lastWideWidth.current = width // Rail-in only crossfades a live collapse: a refresh straight into the // collapsed state renders the rail statically (no delay-hidden icons). const everWide = useRef(!collapsed) if (!collapsed) everWide.current = true // Rail search = expand + land in the search box: the flag arms before the // expand toggle; once expanded the input is mounted and takes focus. const [searchOnExpand, setSearchOnExpand] = useState(false) useEffect(() => { if (!collapsed && searchOnExpand) { const timer = window.setTimeout(() => { searchInput.current?.focus({ preventScroll: true }) setSearchOnExpand(false) }, EXPAND_SLIDE_MS) return () => { window.clearTimeout(timer) } } }, [collapsed, searchOnExpand]) return (
{wide && ( )} {/* Rail resting state is the whale mark; hovering swaps in the panel icon (the expand affordance, figma sidebar-hover flow). */}
{wide && WorkSpace} {wide && }
{/* Expanded: the row is a click-to-focus field (the leading icon is decorative). Collapsed: the icon is the rail's search control. */}
{ if (!collapsed) searchInput.current?.focus() }}> {wide && ( { setQuery(e.target.value) }} /> )} {wide && query !== '' && ( )}
{/* Always-mounted seat: its flex slot pins the foot to the bottom in both states while the tree itself is wide-only. */}
{wide && }
{wide && Settings}
) }