/** * The workspace/session browsing region filling the sidebar shell's * `sidebar.workspaces` hole: section header (title + view options + add * workspace), search, the grouped tree or flat list, and the workspace * dialogs. Fully upgraded to DSH 0.1.7-rc.1 baseline and enriched with * universal fork-tree capabilities across all grouping modes. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconBranchOutlineRegular, IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular, IconFlatListOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, IconPersonalizationOutlineRegular, IconProjectAddOutlineRegular, IconSearchOutlineRegular, IconWorkspaceTreeOutlineRegular, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode, SessionOrderBy, SessionRowState } from '../tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, owningGroupKey, owningParentFolder, pinCurrentBlank, reconcileManualOrder, orderByRecency, sessionMemberIds, UNGROUPED_KEY, type ArchivedFilter, } from '../tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem, rowHalf, } from './Rows.tsx' import { buildPathMap, buildSessionTree, collectBranchIds, findSessionAncestors, flattenSessionTree, } from '../branch.ts' import { uiLabel } from '../ui-label.ts' import { FLAT_SESSION_ORDER_KEY, type SessionGroupBy } from '../stores.ts' import { WorkspacePickFlow } from '../WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' const EXPAND_SLIDE_MS = 300 const SEARCH_DEBOUNCE_MS = 250 const SEARCH_QUERY_MAX_CODE_UNITS = 500 const COLLAPSED_SESSION_LIMIT = 5 function collapsedSessionRows( sessions: readonly SessionNode[], limit = COLLAPSED_SESSION_LIMIT, ): { rows: readonly SessionNode[] hiddenCount: number } { let idleCount = 0 const rows = sessions.filter((session) => { if (session.blank || session.running || session.runningSubagentCount > 0) return true if (idleCount >= limit) return false idleCount += 1 return true }) return { rows, hiddenCount: Math.max(0, sessions.length - rows.length) } } function sanitizeSearchQuery(value: string): string { const withoutNul = value.replaceAll('\0', '') if (withoutNul.length <= SEARCH_QUERY_MAX_CODE_UNITS) return withoutNul let end = SEARCH_QUERY_MAX_CODE_UNITS const last = withoutNul.charCodeAt(end - 1) const next = withoutNul.charCodeAt(end) if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end-- return withoutNul.slice(0, end) } function useNativeDragAcceptance(active: boolean): void { useEffect(() => { if (!active) return const acceptDrag = (event: DragEvent): void => { event.preventDefault() if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move' } const acceptDrop = (event: DragEvent): void => { event.preventDefault() } document.addEventListener('dragover', acceptDrag) document.addEventListener('drop', acceptDrop) return () => { document.removeEventListener('dragover', acceptDrag) document.removeEventListener('drop', acceptDrop) } }, [active]) } function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { const rect = e.currentTarget.getBoundingClientRect() return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } function sessionDragOrder( accountSessionIds: readonly string[], renderedSessions: readonly SessionNode[], activeDrag: { sessionId: string; pinned: boolean }, over: { id: string; half: 'before' | 'after' }, ): string[] | undefined { const targetIndex = renderedSessions.findIndex((s) => s.id === over.id) if (targetIndex === -1) return undefined const anchor = over.half === 'before' ? over.id : renderedSessions[targetIndex + 1]?.id if (anchor === activeDrag.sessionId) return undefined const sourceIndex = accountSessionIds.indexOf(activeDrag.sessionId) const anchorIndex = anchor === undefined ? accountSessionIds.length : accountSessionIds.indexOf(anchor) if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return undefined const next = accountSessionIds.filter((id) => id !== activeDrag.sessionId) const insertAt = anchor === undefined ? next.length : next.indexOf(anchor) next.splice(insertAt === -1 ? next.length : insertAt, 0, activeDrag.sessionId) return next } /** * ViewOptionsMenu: clean native options menu with branch expand/collapse additions. */ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrderPick, onArchivedFilterPick, onExpandAllBranches, onCollapseAllBranches, t, }: { groupBy: SessionGroupBy orderBy: SessionOrderBy archivedFilter: ArchivedFilter onGroupPick: (mode: SessionGroupBy) => void onOrderPick: (mode: SessionOrderBy) => void onArchivedFilterPick: (filter: ArchivedFilter) => void onExpandAllBranches: () => void onCollapseAllBranches: () => void t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) return ( { setOpen(false) }} items={[ { type: 'label', id: 'group-by', text: t('groupBy.label') }, { id: 'workspace', label: t('groupBy.workspace'), icon: , }, { id: 'workspace-tree', label: uiLabel('工作区目录树', 'Workspace Tree'), icon: , }, { id: 'flat', label: t('groupBy.flat'), icon: , }, { type: 'separator', id: 'order-by-separator' }, { type: 'label', id: 'order-by', text: t('orderBy.label') }, { id: 'manual', label: t('orderBy.manual'), icon: , }, { id: 'updated', label: t('orderBy.updated'), icon: , }, { type: 'separator', id: 'archived-filter-separator' }, { type: 'label', id: 'filter-by', text: uiLabel('筛选会话', 'Filter') }, { id: 'show-archived', label: uiLabel('显示已归档会话', 'Show archived sessions'), icon: , }, { id: 'only-archived', label: uiLabel('仅显示已归档会话', 'Only archived sessions'), icon: , }, { type: 'separator', id: 'branch-control-separator' }, { type: 'label', id: 'branch-control', text: uiLabel('分支树控制', 'Branch Controls') }, { id: 'expand-all-branches', label: uiLabel('全部展开分支', 'Expand all branches'), icon: , }, { id: 'collapse-all-branches', label: uiLabel('全部折叠分支', 'Collapse all branches'), icon: , }, ]} selectedIds={[ groupBy, orderBy, ...(archivedFilter === 'show' ? ['show-archived'] : []), ...(archivedFilter === 'only' ? ['only-archived'] : []), ]} onSelect={(id) => { if (id === 'workspace' || id === 'workspace-tree' || id === 'flat') onGroupPick(id) else if (id === 'manual' || id === 'updated') onOrderPick(id) else if (id === 'show-archived') onArchivedFilterPick(archivedFilter === 'show' ? 'default' : 'show') else if (id === 'only-archived') onArchivedFilterPick(archivedFilter === 'only' ? 'default' : 'only') else if (id === 'expand-all-branches') onExpandAllBranches() else if (id === 'collapse-all-branches') onCollapseAllBranches() setOpen(false) }} align="end" dense className={css.viewOptionsMenu} portal anchor={ } /> ) } /** * FlatList with full fork-tree hierarchical projection. */ function FlatList({ list, sessionIds, rowState, useSessionStatus, open, onSessionRenameRequest, renderSlot, usePanelInfo, setSessionOrder, workspaceReady, revealSessionId, onSessionRevealed, collapsedBranches, onToggleBranchCollapse, t, }: { list: SessionListState sessionIds: readonly SessionId[] rowState: SessionRowState useSessionStatus?: any open: (sessionId: SessionId) => void onSessionRenameRequest: (id: SessionId, currentTitle: string) => void renderSlot?: any usePanelInfo?: any setSessionOrder: (accountKey: string, order: readonly string[]) => void workspaceReady: boolean revealSessionId?: string | undefined onSessionRevealed: (id: string) => void collapsedBranches: ReadonlySet onToggleBranchCollapse: (sessionId: SessionId) => void t: WorkspaceBrowserProps['t'] }) { const panelActive = typeof usePanelInfo === 'function' ? usePanelInfo((info: any) => info.activePanelId !== null) : false const statuses = typeof useSessionStatus === 'function' ? useSessionStatus((s: any) => s) : new Map() const currentId = panelActive ? undefined : (Object.values(list.byId).find((session: any) => (session.retainedBy?.mainView ?? 0) > 0)?.id as SessionId | undefined) const rawRows = useMemo( () => deriveFlat(list, sessionIds, rowState, statuses), [list, sessionIds, rowState, statuses], ) const sessionTree = useMemo(() => buildSessionTree(rawRows), [rawRows]) const flatRows = useMemo( () => flattenSessionTree(sessionTree, 0, [], new Set(), collapsedBranches), [sessionTree, collapsedBranches], ) const pathByNode = useMemo(() => buildPathMap(sessionTree), [sessionTree]) const [drag, setDrag] = useState<{ accountKey: string sessionId: string pinned: boolean over: { id: string; half: 'before' | 'after' } | null } | null>(null) const dropCommitted = useRef(false) useNativeDragAcceptance(drag !== null) const commitDrag = ( activeDrag: NonNullable, over: { id: string; half: 'before' | 'after' }, ) => { if (dropCommitted.current) return dropCommitted.current = true setDrag(null) const nextOrder = sessionDragOrder( sessionIds.map(String), flatRows.map((r) => r.node), activeDrag, over, ) if (nextOrder !== undefined) setSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder) } const now = Date.now() return (
{flatRows.length === 0 &&
{t('empty.none')}
} {flatRows.map(({ node, depth }) => { const active = drag !== null && drag.pinned === node.pinned const normalizeHalf = (half: 'before' | 'after') => (node.blank ? 'after' : half) const isCollapsed = collapsedBranches.has(String(node.id)) const ancestors = currentId ? findSessionAncestors(sessionTree, currentId) : null const isCurrentCollapsedAncestor = isCollapsed && ancestors !== null && ancestors.includes(node.id) return ( onToggleBranchCollapse(id as SessionId)} path={pathByNode.get(node.id) || []} currentId={currentId} isCurrentCollapsedAncestor={isCurrentCollapsedAncestor} now={now} onOpen={(id) => open(id as SessionId)} onRenameRequest={(id, title) => onSessionRenameRequest(id as SessionId, title)} renderSlot={renderSlot} onReveal={ node.id === revealSessionId ? () => { onSessionRevealed(node.id) } : undefined } flat drag={{ start: () => { dropCommitted.current = false setDrag({ accountKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, pinned: node.pinned, over: null, }) }, active, marker: active && drag.over?.id === node.id ? drag.over.half : null, hover: (half) => { setDrag((curr) => curr === null ? curr : { ...curr, over: { id: node.id, half: normalizeHalf(half) } }, ) }, drop: (half) => { if (drag !== null) commitDrag(drag, { id: node.id, half: normalizeHalf(half) }) }, end: () => { if (drag?.over !== null && drag?.over !== undefined) commitDrag(drag, drag.over) else setDrag(null) dropCommitted.current = false }, }} t={t} /> ) })}
) } /** * SessionTree: renders hierarchical workspaces and nested fork-trees within each workspace. */ function SessionTree({ list, useSessionStatus, startSession, open, workspaces, ungroupedSessionIds, rowState, workspaceReady, usePanelInfo, onRenameRequest, onDeleteRequest, onSessionRenameRequest, renderSlot, insertWorkspaceBefore, nestWorkspaces, groupExpansion, setGroupExpanded, setSessionOrder, collapsedBranchesByAccount, onToggleBranchCollapse, home, newShortcut, t, revealSessionId, onSessionRevealed, }: { list: SessionListState useSessionStatus?: any startSession: (workspaceId?: WorkspaceId) => void open: (sessionId: SessionId) => void workspaces: readonly WorkspaceView[] ungroupedSessionIds: readonly SessionId[] rowState: SessionRowState workspaceReady: boolean usePanelInfo?: any onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void onDeleteRequest: (workspaceId: WorkspaceId, title: string) => void onSessionRenameRequest: (id: SessionId, currentTitle: string) => void renderSlot?: any insertWorkspaceBefore: (workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId) => Promise nestWorkspaces: boolean groupExpansion: Record setGroupExpanded: (key: string, expanded: boolean) => void setSessionOrder: (accountKey: string, order: readonly string[]) => void collapsedBranchesByAccount: Record onToggleBranchCollapse: (accountKey: string, sessionId: SessionId) => void home: string | undefined newShortcut?: any t: WorkspaceBrowserProps['t'] revealSessionId?: string | undefined onSessionRevealed: (id: string) => void }) { const panelActive = typeof usePanelInfo === 'function' ? usePanelInfo((info: any) => info.activePanelId !== null) : false const statuses = typeof useSessionStatus === 'function' ? useSessionStatus((s: any) => s) : new Map() const current = panelActive ? undefined : (Object.values(list.byId).find((session: any) => (session.retainedBy?.mainView ?? 0) > 0)?.id as SessionId | undefined) const revealGroup = revealSessionId === undefined || !workspaceReady ? undefined : owningGroupKey(workspaces, revealSessionId as SessionId) const [sessionLimits, setSessionLimits] = useState>({}) const [drag, setDrag] = useState<{ accountKey: string sessionId: string pinned: boolean over: { id: string; half: 'before' | 'after' } | null } | null>(null) const sessionDropCommitted = useRef(false) const [workspaceDrag, setWorkspaceDrag] = useState<{ workspaceId: WorkspaceId over: { id: WorkspaceId; half: 'before' | 'after' } | null } | null>(null) const workspaceDropCommitted = useRef(false) const nativeDragActive = drag !== null || workspaceDrag !== null useNativeDragAcceptance(nativeDragActive) const currentGroup = current === undefined || !workspaceReady ? undefined : owningGroupKey(workspaces, current) useEffect(() => { if (current === undefined || currentGroup === undefined || Object.hasOwn(groupExpansion, currentGroup)) return setGroupExpanded(currentGroup, true) }, [current, currentGroup, setGroupExpanded, groupExpansion]) const parents = useMemo(() => { if (!nestWorkspaces) return new Map() const keysByPath = new Map(workspaces.map((workspace) => [workspace.path, workspace.workspaceId])) const paths = [...keysByPath.keys()] return new Map( workspaces.map((workspace) => { const path = owningParentFolder(workspace.path, paths) return [workspace.workspaceId, path === undefined ? undefined : keysByPath.get(path)] }), ) }, [nestWorkspaces, workspaces]) const currentAncestors = useMemo(() => { const keys = new Set() for (let key = currentGroup === undefined ? undefined : parents.get(currentGroup); key !== undefined; key = parents.get(key)) { keys.add(key) } return keys }, [currentGroup, parents]) const expandedGroups = useMemo(() => { const ancestorKeys = new Set(parents.values()) return [...workspaces.map((w) => w.workspaceId), ''].filter( (key) => groupExpansion[key] ?? ancestorKeys.has(key), ) }, [groupExpansion, parents, workspaces]) const groups = useMemo( () => deriveGroups(list, workspaces, rowState, statuses, { expandedGroups, ungroupedOrder: ungroupedSessionIds, }), [list, workspaces, rowState, statuses, expandedGroups, ungroupedSessionIds], ) useEffect(() => { for (let key = revealGroup; key !== undefined; key = parents.get(key)) { if (groupExpansion[key] === false || (key === revealGroup && groupExpansion[key] !== true)) { setGroupExpanded(key, true) } } }, [groupExpansion, parents, revealGroup, setGroupExpanded]) useEffect(() => { if (revealSessionId === undefined || revealGroup === undefined) return const group = groups.find((candidate) => candidate.key === revealGroup) if (group === undefined || !group.expanded || !group.sessions.some((row) => row.id === revealSessionId)) return setSessionLimits((limits) => limits[revealGroup] === Infinity ? limits : { ...limits, [revealGroup]: Infinity }, ) }, [groups, revealGroup, revealSessionId]) const now = Date.now() const commitSessionDrag = ( activeDrag: NonNullable, over: { id: string; half: 'before' | 'after' }, ) => { if (sessionDropCommitted.current) return sessionDropCommitted.current = true setDrag(null) const group = groups.find((candidate) => candidate.key === activeDrag.accountKey) if (group === undefined || over.id === activeDrag.sessionId) return const accountSessionIds = activeDrag.accountKey === '' ? ungroupedSessionIds.map(String) : workspaces.find((w) => w.workspaceId === activeDrag.accountKey)?.sessionIds.map(String) if (accountSessionIds === undefined) return const renderedSessions = collapsedSessionRows(group.sessions, sessionLimits[group.key]).rows const nextOrder = sessionDragOrder(accountSessionIds, renderedSessions, activeDrag, over) if (nextOrder !== undefined) setSessionOrder(activeDrag.accountKey, nextOrder) } const commitWorkspaceDrag = ( activeDrag: NonNullable, over: { id: WorkspaceId; half: 'before' | 'after' }, ) => { if (workspaceDropCommitted.current) return workspaceDropCommitted.current = true setWorkspaceDrag(null) const owner = parents.get(activeDrag.workspaceId) const siblings = workspaces.filter((w) => parents.get(w.workspaceId) === owner) const rowIndex = siblings.findIndex((w) => w.workspaceId === over.id) if (rowIndex === -1) return const anchor = over.half === 'before' ? over.id : siblings[rowIndex + 1]?.workspaceId if (anchor === activeDrag.workspaceId) return const sourceIndex = siblings.findIndex((w) => w.workspaceId === activeDrag.workspaceId) const anchorIndex = anchor === undefined ? siblings.length : siblings.findIndex((w) => w.workspaceId === anchor) if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return insertWorkspaceBefore(activeDrag.workspaceId, anchor).catch((reason) => { console.warn('workspace reorder rejected:', reason) }) } const childrenByParent = useMemo(() => { const children = new Map() for (const group of groups) { const parent = parents.get(group.key) const siblings = children.get(parent) if (siblings === undefined) children.set(parent, [group]) else siblings.push(group) } return children }, [groups, parents]) const rootGroups = childrenByParent.get(undefined) ?? [] const renderGroup = (group: GroupNode, depth: number) => { const workspaceId = group.workspaceId const children = childrenByParent.get(group.key) ?? [] const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key) const sessionTree = buildSessionTree(group.sessions) const collapsedBranchSet = new Set(collapsedBranchesByAccount[group.key] || []) const flattenedRows = flattenSessionTree(sessionTree, 0, [], new Set(), collapsedBranchSet) const pathByNode = buildPathMap(sessionTree) const limit = sessionLimits[group.key] ?? COLLAPSED_SESSION_LIMIT const visibleFlattened = flattenedRows.slice(0, limit) const sessionsExpanded = visibleFlattened.length >= flattenedRows.length const childRows = group.expanded ? children.map((child) => renderGroup(child, depth + 1)) : [] const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId ? workspaceDrag.over.half : null const workspaceDragProps = workspaceId === undefined ? undefined : { start: () => { workspaceDropCommitted.current = false setWorkspaceDrag({ workspaceId, over: null }) }, end: () => { if (workspaceDrag?.over !== null && workspaceDrag?.over !== undefined) { commitWorkspaceDrag(workspaceDrag, workspaceDrag.over) } else setWorkspaceDrag(null) workspaceDropCommitted.current = false }, } const hoverWorkspace = workspaceId === undefined || !compatibleDrag ? undefined : (half: 'before' | 'after') => { setWorkspaceDrag((active) => active === null ? active : { ...active, over: { id: workspaceId, half } }, ) } const dropWorkspace = workspaceId === undefined || !compatibleDrag ? undefined : (half: 'before' | 'after') => { commitWorkspaceDrag(workspaceDrag!, { id: workspaceId, half }) } return (
{ e.preventDefault() if (hoverWorkspace === undefined && parents.get(group.key) !== undefined) return e.stopPropagation() if (hoverWorkspace === undefined) { e.dataTransfer.dropEffect = 'none' if (workspaceDrag.over !== null) setWorkspaceDrag({ ...workspaceDrag, over: null }) } else { e.dataTransfer.dropEffect = 'move' hoverWorkspace(workspaceGroupHalf(e)) } } } onDrop={ workspaceDrag === null ? undefined : (e) => { e.preventDefault() if (dropWorkspace === undefined && parents.get(group.key) !== undefined) return e.stopPropagation() if (dropWorkspace === undefined) { workspaceDropCommitted.current = true setWorkspaceDrag(null) } else dropWorkspace(workspaceGroupHalf(e)) } } > { if (group.expanded) { setSessionLimits((limits) => ({ ...limits, [group.key]: COLLAPSED_SESSION_LIMIT })) } setGroupExpanded(group.key, !group.expanded) }} onCreate={() => { if (group.workspaceId !== undefined) { setGroupExpanded(group.key, true) startSession(group.workspaceId) } }} drag={workspaceDragProps} actions={ group.workspaceId === undefined ? undefined : { rename: () => { if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) }, delete: () => { if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) }, } } /> {childRows.length > 0 &&
{childRows}
} {group.expanded && visibleFlattened.map(({ node, depth: nodeDepth }) => { const sameGroupDrag = drag !== null && drag.accountKey === group.key const compatibleTarget = sameGroupDrag && drag.pinned === node.pinned const normalizeHalf = (half: 'before' | 'after') => (node.blank ? 'after' : half) const isBranchCollapsed = collapsedBranchSet.has(String(node.id)) const ancestors = current ? findSessionAncestors(sessionTree, current) : null const isCurrentCollapsedAncestor = isBranchCollapsed && ancestors !== null && ancestors.includes(node.id) const dragProps = { start: () => { sessionDropCommitted.current = false setDrag({ accountKey: group.key, sessionId: node.id, pinned: node.pinned, over: null, }) }, active: compatibleTarget, marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, hover: (half: 'before' | 'after') => { setDrag((d) => d === null ? d : { ...d, over: { id: node.id, half: normalizeHalf(half) } }, ) }, drop: (half: 'before' | 'after') => { if (drag === null) return commitSessionDrag(drag, { id: node.id, half: normalizeHalf(half) }) }, end: () => { if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) else setDrag(null) sessionDropCommitted.current = false }, } return ( onToggleBranchCollapse(group.key, id as SessionId)} path={pathByNode.get(node.id) || []} currentId={current} isCurrentCollapsedAncestor={isCurrentCollapsedAncestor} now={now} onOpen={(id) => open(id as SessionId)} onRenameRequest={(id, title) => onSessionRenameRequest(id as SessionId, title)} renderSlot={renderSlot} onReveal={ node.id === revealSessionId && group.key === revealGroup ? () => { onSessionRevealed(node.id) } : undefined } drag={dragProps} t={t} /> ) })} {group.expanded && flattenedRows.length > COLLAPSED_SESSION_LIMIT && ( )}
) } return (
{groups.length === 0 &&
{t('empty.none')}
} {rootGroups.map((group) => renderGroup(group, 0))}
) } /** * SearchResults wrapper passing standard actions. */ function SearchResults({ useSessions, useSessionStatus, open, onUnarchive, workspaces, archivedSessionIds, archivedFilter, query, remote, resultLimit, t, }: { useSessions: any useSessionStatus?: any open: (sessionId: SessionId) => void onUnarchive?: (sessionId: SessionId) => void workspaces: readonly WorkspaceView[] archivedSessionIds: readonly string[] archivedFilter: ArchivedFilter query: string remote: { query: string status: 'idle' | 'loading' | 'ready' | 'error' items: readonly any[] hasMore: boolean } resultLimit: number t: WorkspaceBrowserProps['t'] }) { const list = useSessions((s: any) => s) const statuses = typeof useSessionStatus === 'function' ? useSessionStatus((s: any) => s) : new Map() const results = useMemo( () => deriveSearchResults( list, workspaces, query, archivedSessionIds as any, archivedFilter, statuses, remote, resultLimit, ), [list, workspaces, query, archivedSessionIds, archivedFilter, statuses, remote, resultLimit], ) return (
{results.items.length === 0 &&
{uiLabel('未找到匹配会话', 'No matching sessions')}
} {results.items.map((result) => ( open(id as SessionId)} onUnarchive={onUnarchive ? (id) => onUnarchive(id as SessionId) : undefined} t={t} /> ))} {results.hasMore && (
{uiLabel('输入更详细的关键词以精确匹配', 'Narrow query for more matches')}
)}
) } /** * Root WorkspaceBrowser component. */ export function WorkspaceBrowser({ wide, usePanelInfo, expandSidebar, useSessions, useSessionStatus, useWorkspaces, useStore, actions, startSession, open, requestSessionRename, notifyArchivedNotOpenable, renameWorkspace, deleteWorkspace, insertWorkspaceBefore, unarchiveSession, createWorkspace, searchSessions, searchResultLimit, useDirectoryFlow, useHostInfo, useShortcuts, useWorkspaceShortcuts, requestSearch, requestAddWorkspace, closeAddWorkspace, setDirectoryBusy, dismissForkError, renderSlot, t, }: WorkspaceBrowserProps) { const shortcuts = typeof useShortcuts === 'function' ? useShortcuts((s: any) => s) : undefined const newShortcut = Array.isArray(shortcuts) ? shortcuts.find((s: any) => s?.id === 'session.new') : undefined const home = useHostInfo((info: any) => info?.home) const list = useSessions((state: any) => state) const workspaces = useWorkspaces((state: any) => state.items) const workspacePhase = useWorkspaces((state: any) => state.phase) const workspaceStreamState = useWorkspaces((state: any) => state.state) const archivedSessionIds = useWorkspaces((state: any) => (state.archivedSessionIds ?? []).map(String)) const pinnedSessionIds = useWorkspaces((state: any) => (state.pinnedSessionIds ?? []).map(String)) const directoryFlowAvailable = useDirectoryFlow((occupied: any) => !!occupied) const groupBy = useStore((s: any) => s.groupBy) const orderBy = useStore((s: any) => s.orderBy) const archivedFilter = useStore((s: any) => s.archivedFilter ?? 'default') const groupExpansion = useStore((s: any) => s.groupExpansion) const sessionOrderByAccount = useStore((s: any) => s.sessionOrderByAccount) const BRANCH_COLLAPSE_STORAGE_KEY = 'dsh.branch.collapsed.v1' const storeCollapsedBranches = useStore((s: any) => s.collapsedBranchesByAccount) const [localCollapsedBranches, setLocalCollapsedBranches] = useState>(() => { if (typeof localStorage === 'undefined') return {} try { const saved = localStorage.getItem(BRANCH_COLLAPSE_STORAGE_KEY) return saved ? JSON.parse(saved) : {} } catch { return {} } }) // Merge store state and local state with precedence given to explicit toggles const collapsedBranchesByAccount = useMemo(() => { const merged: Record = {} if (storeCollapsedBranches && typeof storeCollapsedBranches === 'object') { for (const [k, v] of Object.entries(storeCollapsedBranches)) { if (Array.isArray(v)) merged[k] = [...v] } } for (const [k, v] of Object.entries(localCollapsedBranches)) { if (Array.isArray(v)) merged[k] = [...v] } return merged }, [storeCollapsedBranches, localCollapsedBranches]) const saveLocalBranches = (next: Record) => { setLocalCollapsedBranches(next) if (typeof localStorage !== 'undefined') { try { localStorage.setItem(BRANCH_COLLAPSE_STORAGE_KEY, JSON.stringify(next)) } catch {} } } const handleToggleBranchCollapse = (accountKey: string, sessionId: SessionId) => { const sid = String(sessionId) const currentList = Array.isArray(collapsedBranchesByAccount[accountKey]) ? collapsedBranchesByAccount[accountKey] : [] const isCollapsed = currentList.includes(sid) const nextList = isCollapsed ? currentList.filter((id) => id !== sid) : [...currentList, sid] if (typeof actions?.setBranchCollapsed === 'function') { try { actions.setBranchCollapsed(accountKey, sid, !isCollapsed) } catch (err) { console.warn('[branch-workspace] actions.setBranchCollapsed failed, fallback to local', err) } } const nextState = { ...collapsedBranchesByAccount, [accountKey]: nextList, } saveLocalBranches(nextState) } const handleExpandAllBranches = () => { const allKeys = ['', FLAT_SESSION_ORDER_KEY, ...workspaces.map((w: any) => w.workspaceId)] if (typeof actions?.setAllBranchesCollapsed === 'function') { try { for (const key of allKeys) actions.setAllBranchesCollapsed(key, [], false) } catch (err) { console.warn('[branch-workspace] actions.setAllBranchesCollapsed failed, fallback to local', err) } } const nextState: Record = {} for (const key of allKeys) nextState[key] = [] saveLocalBranches(nextState) } const handleCollapseAllBranches = () => { const allSessions = list.ids.map((id: any) => list.byId[id]).filter(Boolean) const allBranchIds = collectBranchIds(allSessions as any) const allKeys = ['', FLAT_SESSION_ORDER_KEY, ...workspaces.map((w: any) => w.workspaceId)] if (typeof actions?.setAllBranchesCollapsed === 'function') { try { for (const key of allKeys) actions.setAllBranchesCollapsed(key, allBranchIds, true) } catch (err) { console.warn('[branch-workspace] actions.setAllBranchesCollapsed failed, fallback to local', err) } } const nextState: Record = {} for (const key of allKeys) nextState[key] = [...allBranchIds] saveLocalBranches(nextState) } const guardedOpen = (sessionId: SessionId) => { if (archivedSessionIds.includes(String(sessionId))) { notifyArchivedNotOpenable() return } open(sessionId) } const workspaceReady = workspacePhase === 'ready' && workspaceStreamState !== 'loading' const mainSessionId = (Object.values(list.byId) as any[]).find( (session: any) => (session.retainedBy?.mainView ?? 0) > 0, )?.id as SessionId | undefined const currentBlank = mainSessionId !== undefined && list.byId[mainSessionId]?.blank === true ? mainSessionId : undefined const ungroupedMemberIds = useMemo(() => { const accounted = new Set(workspaces.flatMap((w: any) => w.sessionIds.map(String))) return list.ids.filter((id: any) => list.byId[id] !== undefined && !accounted.has(String(id))) as SessionId[] }, [list, workspaces]) const orderState = useMemo( () => ({ pinnedSessionIds: pinnedSessionIds as any, archivedSessionIds: archivedSessionIds as any }), [archivedSessionIds, pinnedSessionIds], ) const rowState: SessionRowState = useMemo( () => ({ ...orderState, archivedFilter }), [orderState, archivedFilter], ) const flatMemberIds = useMemo(() => sessionMemberIds(list), [list]) const orderedWorkspaces = useMemo( () => workspaces.map((workspace: any) => { const memberIds = workspace.sessionIds as SessionId[] const baseOrder = orderBy === 'updated' ? orderByRecency(memberIds, list.byId) : reconcileManualOrder( memberIds, sessionOrderByAccount[workspace.workspaceId], list.byId, orderState, ) return { ...workspace, sessionIds: pinCurrentBlank( baseOrder, currentBlank !== undefined && memberIds.includes(currentBlank) ? currentBlank : undefined, ), } }), [currentBlank, list.byId, orderBy, orderState, sessionOrderByAccount, workspaces], ) const orderedUngroupedSessionIds = useMemo(() => { return pinCurrentBlank( orderBy === 'updated' ? orderByRecency(ungroupedMemberIds, list.byId) : reconcileManualOrder( ungroupedMemberIds, sessionOrderByAccount[''], list.byId, orderState, ), currentBlank !== undefined && ungroupedMemberIds.includes(currentBlank) ? currentBlank : undefined, ) }, [currentBlank, list.byId, orderBy, orderState, sessionOrderByAccount, ungroupedMemberIds]) const orderedFlatSessionIds = useMemo(() => { return pinCurrentBlank( orderBy === 'updated' ? orderByRecency(flatMemberIds, list.byId) : reconcileManualOrder( flatMemberIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY], list.byId, orderState, ), currentBlank !== undefined && flatMemberIds.includes(currentBlank) ? currentBlank : undefined, ) }, [currentBlank, flatMemberIds, list.byId, orderBy, orderState, sessionOrderByAccount]) const activeSessionOrders = useMemo( () => Object.fromEntries([ ...orderedWorkspaces.map((workspace: any) => [workspace.workspaceId, workspace.sessionIds.map(String)]), ['', orderedUngroupedSessionIds.map(String)], [FLAT_SESSION_ORDER_KEY, orderedFlatSessionIds.map(String)], ]), [orderedFlatSessionIds, orderedUngroupedSessionIds, orderedWorkspaces], ) useEffect(() => { if (workspacePhase !== 'ready') return actions.retainAccountKeys([ '', FLAT_SESSION_ORDER_KEY, ...workspaces.map((workspace: any) => workspace.workspaceId), ]) }, [actions, workspacePhase, workspaces]) useEffect(() => { if (list.phase !== 'ready' || workspaceReady || orderBy !== 'manual' || currentBlank === undefined) return const changed: Record = {} for (const [key, ids] of Object.entries(activeSessionOrders)) { if (key !== FLAT_SESSION_ORDER_KEY && workspacePhase !== 'ready') continue const saved = sessionOrderByAccount[key] ?? [] if ((ids as any)[0] !== currentBlank || saved[0] === currentBlank) continue changed[key] = [currentBlank, ...saved.filter((id: string) => id !== currentBlank)] } if (Object.keys(changed).length > 0) actions.syncSessionOrders(changed) }, [ actions.syncSessionOrders, activeSessionOrders, currentBlank, list.phase, orderBy, sessionOrderByAccount, workspacePhase, workspaceReady, ]) useEffect(() => { if (list.phase !== 'ready' || !workspaceReady || orderBy !== 'manual' || currentBlank === undefined) return if ( Object.entries(activeSessionOrders).some( ([key, ids]: [string, any]) => ids[0] === currentBlank && sessionOrderByAccount[key]?.[0] !== currentBlank, ) ) { actions.syncSessionOrders(activeSessionOrders) } }, [ actions.syncSessionOrders, activeSessionOrders, currentBlank, list.phase, orderBy, sessionOrderByAccount, workspaceReady, ]) const saveSessionOrder = (accountKey: string, order: readonly string[]) => { actions.setSessionOrder(accountKey, order, activeSessionOrders) } const [query, setQuery] = useState('') const [searchExpanded, setSearchExpanded] = useState(false) const [revealSessionId, setRevealSessionId] = useState(undefined) const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState<{ query: string status: 'idle' | 'loading' | 'ready' | 'error' items: readonly any[] hasMore: boolean }>({ query: '', status: 'idle', items: [], hasMore: false, }) const searchRoot = useRef(null) const searchInput = useRef(null) const [wsPickerOpen, setWsPickerOpen] = useState(false) const wsPlusRef = useRef(null) const composingRef = useRef(false) const revealTargetSession = useCallback( (sessionId: string) => { setRevealSessionId(sessionId) const groupKey = owningGroupKey(workspaces, sessionId as SessionId) const workspaceSessionIds = new Set(workspaces.flatMap((w: any) => w.sessionIds || [])) let groupSessions = groupKey === '' ? list.ids.map((id: any) => list.byId[id]).filter((s: any) => s && !workspaceSessionIds.has(s.id)) : list.ids .map((id: any) => list.byId[id]) .filter( (s: any) => s && (workspaces.find((w: any) => w.workspaceId === groupKey)?.sessionIds.includes(s.id) || groupKey === FLAT_SESSION_ORDER_KEY), ) // Fallback to all sessions if the target was not found in the scoped subset (e.g. cross-account lineage) if (!groupSessions.some((s: any) => s?.id === sessionId)) { groupSessions = list.ids.map((id: any) => list.byId[id]).filter(Boolean) } const tree = buildSessionTree(groupSessions as any) const ancestors = findSessionAncestors(tree, sessionId) if (ancestors && ancestors.length > 0) { for (const ancestorId of ancestors) { actions.setBranchCollapsed?.(groupKey, ancestorId, false) actions.setBranchCollapsed?.(FLAT_SESSION_ORDER_KEY, ancestorId, false) } } }, [actions, list.byId, list.ids, workspaces], ) const openSearchResult = (sessionId: SessionId) => { if (archivedSessionIds.includes(String(sessionId))) { notifyArchivedNotOpenable() return } revealTargetSession(String(sessionId)) setQuery('') setSearchExpanded(false) open(sessionId) } const acknowledgeSessionReveal = (sessionId: string) => { setRevealSessionId((current) => (current === sessionId ? undefined : current)) } useEffect(() => { if (normalizedQuery !== '') setRevealSessionId(undefined) }, [normalizedQuery]) 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]) useEffect(() => { if (!wide || !searchExpanded || searchOnExpand) return searchInput.current?.focus({ preventScroll: true }) }, [wide, searchExpanded, searchOnExpand]) useEffect(() => { if (!wide || !searchExpanded || searchOnExpand) return const onClick = (event: MouseEvent) => { if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) { return } searchInput.current?.blur() if (normalizedQuery !== '') return setSearchExpanded(false) } document.addEventListener('click', onClick) return () => { document.removeEventListener('click', onClick) } }, [normalizedQuery, wide, searchExpanded, searchOnExpand]) useEffect(() => { if (normalizedQuery === '') { setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false }) return } const controller = new AbortController() setRemoteSearch({ query: normalizedQuery, status: 'loading', items: [], hasMore: false }) const timer = window.setTimeout(() => { searchSessions(normalizedQuery, controller.signal) .then((result: any) => { if (controller.signal.aborted) return setRemoteSearch({ query: normalizedQuery, status: 'ready', items: result.items, hasMore: result.hasMore, }) }) .catch(() => { if (controller.signal.aborted) return setRemoteSearch({ query: normalizedQuery, status: 'error', items: [], hasMore: false }) }) }, SEARCH_DEBOUNCE_MS) return () => { window.clearTimeout(timer) controller.abort() } }, [normalizedQuery, searchSessions]) const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId currentTitle: string } | null>(null) const [renameDraft, setRenameDraft] = useState('') const [renaming, setRenaming] = useState(false) const [renameError, setRenameError] = useState(null) const renameTrimmed = renameDraft.trim() const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle && workspaces.some((w: any) => 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) return setRenaming(true) setRenameError(null) renameWorkspace(renameTarget.workspaceId, renameTrimmed) .then(() => { setRenaming(false) setRenameTarget(null) }) .catch((reason: any) => { setRenaming(false) setRenameError(reason instanceof Error ? reason.message : String(reason)) }) } const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId title: string } | null>(null) const [deleting, setDeleting] = useState(false) const [deleteCommittedId, setDeleteCommittedId] = useState(null) const [deleteError, setDeleteError] = useState(null) useEffect(() => { if ( deleteCommittedId === null || workspaces.some((workspace: any) => workspace.workspaceId === deleteCommittedId) ) { return } setDeleting(false) setDeleteCommittedId(null) setDeleteTarget(null) }, [deleteCommittedId, workspaces]) const closeDelete = () => { if (deleting) return setDeleteTarget(null) setDeleteError(null) } const confirmDelete = () => { if (deleting || deleteTarget === null) return setDeleting(true) setDeleteError(null) deleteWorkspace(deleteTarget.workspaceId) .then(() => { setDeleteCommittedId(deleteTarget.workspaceId) }) .catch((reason: any) => { setDeleting(false) setDeleteError(reason instanceof Error ? reason.message : String(reason)) }) } return (
{wide && ( {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} )} {wide && (
{ setWsPickerOpen(false) setSearchExpanded(true) searchInput.current?.focus() }} > { setQuery(sanitizeSearchQuery(e.target.value)) }} onKeyDown={(e) => { if (e.key !== 'Escape') return setQuery('') setSearchExpanded(false) }} /> {searchExpanded && ( )}
)}
{wide && ( { actions.setGroupBy(mode) }} onOrderPick={(mode) => { actions.setOrderBy(mode, activeSessionOrders) }} onArchivedFilterPick={(filter) => { actions.setArchivedFilter(filter) }} onExpandAllBranches={handleExpandAllBranches} onCollapseAllBranches={handleCollapseAllBranches} t={t} /> )} {directoryFlowAvailable && ( )}
renderSlot?.('sidebar.workspaces.directoryFlow', owner) } addOnly={true} side="right" onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) }} onClose={() => { setWsPickerOpen(false) }} />
{!wide && (
)}
{wide && (normalizedQuery !== '' ? ( unarchiveSession(id)} workspaces={workspaces} archivedSessionIds={archivedSessionIds} archivedFilter={archivedFilter} query={normalizedQuery} remote={remoteSearch} resultLimit={searchResultLimit} t={t} /> ) : groupBy === 'flat' ? ( requestSessionRename(id as SessionId, title) } renderSlot={renderSlot} usePanelInfo={usePanelInfo} setSessionOrder={saveSessionOrder} workspaceReady={workspaceReady} revealSessionId={revealSessionId} onSessionRevealed={acknowledgeSessionReveal} collapsedBranches={ new Set(collapsedBranchesByAccount[FLAT_SESSION_ORDER_KEY] || []) } onToggleBranchCollapse={(id) => handleToggleBranchCollapse(FLAT_SESSION_ORDER_KEY, id) } t={t} /> ) : ( { setRenameTarget({ workspaceId, currentTitle }) setRenameDraft(currentTitle) setRenameError(null) }} onDeleteRequest={(workspaceId, title) => { setDeleteTarget({ workspaceId, title }) setDeleteError(null) }} onSessionRenameRequest={(id, title) => requestSessionRename(id as SessionId, title) } renderSlot={renderSlot} insertWorkspaceBefore={insertWorkspaceBefore} nestWorkspaces={groupBy === 'workspace-tree'} groupExpansion={groupExpansion} setGroupExpanded={actions.setGroupExpanded} setSessionOrder={saveSessionOrder} collapsedBranchesByAccount={collapsedBranchesByAccount} onToggleBranchCollapse={handleToggleBranchCollapse} home={home} newShortcut={newShortcut} t={t} revealSessionId={revealSessionId} onSessionRevealed={acknowledgeSessionReveal} /> ))}
{/* Rename dialog */} } > { setRenameDraft(e.target.value) }} onCompositionStart={() => { composingRef.current = true }} onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(e) => { if (e.key === 'Enter' && !composingRef.current && !renameBlocked) confirmRename() else if (e.key === 'Escape') closeRename() }} /> {renameError &&
{renameError}
}
{/* Delete confirmation dialog */} } >
{uiLabel('仅删除工作区注册记录,会话日志和目录文件保留。', 'Deletes only the workspace registration record; files remain.')}
{deleteError &&
{deleteError}
}
) }