/**
* 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 { 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 (
{ 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={(
{ setOpen(v => !v) }}
>
)}
/>
)
}
/** 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
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
onDeleteRequest: (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, onDeleteRequest, insertSessionBefore,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState([])
const [expandedSessions, setExpandedSessions] = useState([])
// Transient drag viewing state (never store-bound; order truth stays Host-side).
const [drag, setDrag] = useState(null)
const currentGroup = current === undefined
? undefined
: (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 (
{groups.length === 0 && (
{query === '' ? 'No sessions yet' : 'No matches'}
)}
{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).
{ setExpandedProjects(l => toggled(l, group.key)) }}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
actions={group.workspaceId === undefined
? undefined
: {
rename: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
},
delete: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
},
}}
/>
{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 (
{ setExpandedSessions(l => toggled(l, id)) }}
drag={dragProps}
/>
)
})}
))}
)
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, query }: Pick) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now()
return (
{rows.length === 0 && (
{query === '' ? 'No sessions yet' : 'No matches'}
)}
{rows.map(node => (
{}}
flat
/>
))}
)
}
/**
* 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,
deleteWorkspace,
insertSessionBefore,
createWorkspace,
pickDirectory,
}: 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(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(null)
const composingRef = useRef(false)
// 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(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) 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))
})
}
// Delete dialog is separate from the row so a successful removal can
// unmount that row without tearing down the in-flight confirmation state.
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 => workspace.workspaceId === deleteCommittedId)) return
setDeleting(false)
setDeleteCommittedId(null)
setDeleteTarget(null)
}, [deleteCommittedId, workspaces])
const closeDelete = () => {
if (deleting) return
setDeleteTarget(null)
setDeleteError(null)
}
const confirmDelete = () => {
/* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */
if (deleting || deleteTarget === null) return
setDeleting(true)
setDeleteCommittedId(null)
setDeleteError(null)
deleteWorkspace(deleteTarget.workspaceId).then(() => {
// Keep the confirmation pending until this component has rendered the
// committed list projection without the deleted id. Closing earlier
// exposes one stale React frame to the next Create Workspace gesture.
setDeleteCommittedId(deleteTarget.workspaceId)
}).catch((reason: unknown) => {
setDeleting(false)
setDeleteError(reason instanceof Error ? reason.message : String(reason))
})
}
return (
{wide && (
{groupBy === 'flat' ? 'Sessions' : 'Workspaces'}
)}
{wide && { actions.setGroupBy(mode) }} />}
{
setWsPickerOpen(v => !v)
}}
>
{/* Picker menu + create dialogs (same package — direct composition). */}
{
setWsPickerOpen(false)
startSession(workspaceId)
}}
onClose={() => { setWsPickerOpen(false) }}
/>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Rail: the icon is the region's search control. */}
{ if (wide) searchInput.current?.focus() }}>
{ if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
>
{wide && (
{ setQuery(e.target.value) }}
/>
)}
{wide && query !== '' && (
{ setQuery('') }}
>
)}
{/* Always-mounted seat keeps the region's flex slot while the list
itself is wide-only. */}
{wide && (groupBy === 'flat'
?
: (
{
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
/>
))}
Cancel
Rename
>
)}
>
{ e.target.select() }}
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !composingRef.current) {
e.preventDefault()
confirmRename()
}
}}
/>
{renameDuplicate && (
A workspace named “{renameTrimmed}” already exists.
)}
{renameError !== null && {renameError}
}
Cancel
Delete workspace
>
)}
>
{deleting && Deleting workspace…
}
{deleteError !== null && {deleteError}
}
)
}