/**
* 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 (