Merge remote-tracking branch 'origin/master' into fix/session-waiting-approval

This commit is contained in:
imccyu
2026-08-03 16:51:18 +08:00
2085 files changed
+169828 -14459

No files matched your search

@@ -217,6 +217,26 @@
scrollbar-gutter: stable;
}
.list > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchTree > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchStatus,
.searchWarning {
padding: 10px 12px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.searchWarning {
color: var(--dsw-alias-label-secondary);
}
/* One workspace section: header row + expanded session run. Rows inside
keep the former flat-list 4px gap as sibling margins; the inter-group
breathing room (figma 133:7661 batch separator, 20px after an expanded
@@ -1,11 +1,13 @@
/**
* The workspace/session browsing region filling the sidebar shell's
* `sidebar.workspaces` hole: section header (title + group-by + new
* `sidebar.workspaces` hole: section header (title + group-by + add
* 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).
* region icons (search / add workspace), each requesting shell expansion
* through the owner share. Adding is the header button's one action, so it
* raises the directory flow with no menu in between; the flow and its error
* dialog live in WorkspacePicker (same package — direct composition, no slot
* between them).
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -13,12 +15,14 @@ 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 {
SessionSearchResultItem, 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 { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
/**
@@ -26,12 +30,21 @@ import css from './WorkspaceBrowser.module.css'
* focus() forces a synchronous layout and would jank the slide.
*/
const EXPAND_SLIDE_MS = 300
/** Pause between the latest keystroke and a Host content-search request. */
const SEARCH_DEBOUNCE_MS = 250
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
const SEARCH_QUERY_MAX_CODE_UNITS = 500
const GROUP_BY_ITEMS = [
{ type: 'label' as const, id: 'group-by', text: 'Group by' },
{ id: 'workspace', label: 'WorkSpace' },
{ id: 'flat', label: 'In one list' },
]
/** Keep controlled input and RPC payload inside the session.search wire contract. */
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)
}
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
@@ -39,16 +52,21 @@ function toggled(list: readonly string[], key: string): string[] {
}
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
function GroupByMenu({ groupBy, onPick }: {
function GroupByMenu({ groupBy, onPick, t }: {
groupBy: 'workspace' | 'flat'
onPick: (mode: 'workspace' | 'flat') => void
t: WorkspaceBrowserProps['t']
}) {
const [open, setOpen] = useState(false)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={GROUP_BY_ITEMS}
items={[
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
{ id: 'workspace', label: t('groupBy.workspace') },
{ id: 'flat', label: t('groupBy.flat') },
]}
selectedId={groupBy}
onSelect={(id) => {
/* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */
@@ -63,7 +81,7 @@ function GroupByMenu({ groupBy, onPick }: {
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
aria-label={t('groupBy.label')}
onClick={() => { setOpen(v => !v) }}
>
<IconPersonalizationOutline16 />
@@ -83,28 +101,29 @@ interface DragState {
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
> & {
workspaces: readonly WorkspaceView[]
/** Live search filter owned by the browser root (the query outlives the tree). */
query: string
/** Registry-global archive set (hidden rows). */
archivedSessionIds: readonly SessionNode['id'][]
/** 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
/** Open the browser-owned session rename dialog. */
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
/** Archive a session (row menu action; the row disappears on the state echo). */
onSessionArchive: (sessionId: SessionNode['id']) => 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, onSessionRename, insertSessionBefore,
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
// Transient drag viewing state (never store-bound; order truth stays Host-side).
const [drag, setDrag] = useState<DragState | null>(null)
const currentGroup = current === undefined
@@ -116,24 +135,25 @@ function SessionTree({
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
[list, workspaces, expandedProjects, expandedSessions, query],
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }),
[list, workspaces, archivedSessionIds, expandedProjects],
)
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
{groups.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
<div className={css.empty}>{t('empty.none')}</div>
)}
{groups.map(group => (
// Group section: header row + expanded session subtree. The
// Group section: header row + expanded top-level session rows. The
// inter-group breathing room (former flat-list batch separator)
// is the section's own margin (WorkspaceBrowser.module.css).
<div key={group.key} className={css.groupSection}>
<ProjectRowItem
group={group}
t={t}
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
@@ -152,10 +172,10 @@ function SessionTree({
}}
/>
{group.sessions.map((node, index) => {
// Draggable: real-workspace group roots outside search. The drag
// Draggable: real-workspace session rows. 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 draggable = group.workspaceId !== undefined
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
start: () => {
@@ -170,15 +190,15 @@ function SessionTree({
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
const sessions = 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
const anchor = half === 'before' ? node.id : sessions[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)
const sourceIndex = sessions.findIndex(r => r.id === drag.sessionId)
const anchorIndex = anchor === undefined ? sessions.length : sessions.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)
@@ -190,13 +210,14 @@ function SessionTree({
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={current}
now={now}
onOpen={open}
onRename={onSessionRename}
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
onFork={forkSession}
onArchive={onSessionArchive}
drag={dragProps}
t={t}
/>
)
})}
@@ -209,28 +230,29 @@ function SessionTree({
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
>) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds])
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
{rows.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
<div className={css.empty}>{t('empty.none')}</div>
)}
{rows.map(node => (
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={list.current}
now={now}
onOpen={open}
onRename={onSessionRename}
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
onToggle={() => {}}
flat
onFork={forkSession}
onArchive={onSessionArchive}
t={t}
/>
))}
</div>
@@ -239,6 +261,76 @@ function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTre
)
}
interface RemoteSearchState {
query: string
status: 'idle' | 'loading' | 'ready' | 'error'
items: readonly SessionSearchResultItem[]
hasMore: boolean
}
/** Flat search body: local metadata matches plus the current Host result page. */
function SearchResults({
useSessions,
open,
workspaces,
archivedSessionIds,
query,
remote,
resultLimit,
t,
}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
workspaces: readonly WorkspaceView[]
archivedSessionIds: readonly SessionNode['id'][]
query: string
remote: RemoteSearchState
resultLimit: number
}) {
const list = useSessions(s => s)
const currentRemote = remote.query === query
? remote
: { query, status: 'loading' as const, items: [], hasMore: false }
const results = useMemo(
() => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit),
[list, workspaces, query, archivedSessionIds, currentRemote, resultLimit],
)
const pending = currentRemote.status === 'loading'
const failed = currentRemote.status === 'error'
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list}>
<div className={css.searchTree} role="tree" aria-label={t('search.results.aria')}>
{results.items.map(result => (
<SearchResultItem
key={result.id}
result={result}
currentId={list.current}
onOpen={open}
/>
))}
</div>
{pending && (
<div className={css.searchStatus} role="status">{t('search.pending')}</div>
)}
{failed && (
<div className={css.searchWarning} role="status">
{t('search.unavailable')}
</div>
)}
{!pending && results.items.length === 0 && (
<div className={css.empty}>{t('search.noMatches')}</div>
)}
{results.hasMore && (
<div className={css.searchStatus}>
{t('search.hasMore', { n: resultLimit })}
</div>
)}
</div>
<span className={css.fade} />
</div>
)
}
/**
* Render the browsing region.
* @param props - composed slot props (shell owner share + store + injected actions).
@@ -254,18 +346,34 @@ export function WorkspaceBrowser({
startSession,
open,
renameSession,
forkSession,
renameWorkspace,
deleteWorkspace,
archiveSession,
insertSessionBefore,
createWorkspace,
searchSessions,
searchResultLimit,
useDirectoryFlow,
renderSlot,
t,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
// Live occupancy of this surface's directory-flow hole (the same source the
// flow reads): a composition without a picking affordance can add nothing.
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
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 normalizedQuery = sanitizeSearchQuery(query).trim()
const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({
query: '',
status: 'idle',
items: [],
hasMore: false,
})
const searchInput = useRef<HTMLInputElement | null>(null)
// Section-header opens the picker menu (same popover in wide and rail
// states; the menu anchors on this button).
@@ -286,6 +394,43 @@ export function WorkspaceBrowser({
}
}, [wide, 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) => {
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])
// 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('')
@@ -347,6 +492,16 @@ export function WorkspaceBrowser({
setSessionRenameError(null)
}
// Archive is dialog-free: not destructive (the log and the accounting slot
// remain), so the menu action commits directly; the row disappears when the
// archive-set echo lands. Failures are non-fatal console diagnostics, the
// same posture as reorder rejections.
const onSessionArchive = (sessionId: SessionNode['id']) => {
archiveSession(sessionId).catch((reason: unknown) => {
console.warn('session archive rejected:', 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)
@@ -387,32 +542,38 @@ export function WorkspaceBrowser({
<div className={css.sectionHeader}>
{wide && (
<span className={clsx(css.sectionLabel, css.wide)}>
{groupBy === 'flat' ? 'Sessions' : 'Workspaces'}
{groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
</span>
)}
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} />}
<Tooltip label="New Workspace" disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label="Create workspace"
onClick={() => {
setWsPickerOpen(v => !v)
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
{/* Picker menu + create dialogs (same package — direct composition). */}
<WorkspaceCreateFlow
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} t={t} />}
{/* Adding is the button's one action, so a composition with no
picking affordance has nothing to offer here: the region hides the
button rather than leaving a dead one in the header. */}
{directoryFlowAvailable && (
<Tooltip label={t('workspace.add')} disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label={t('workspace.add')}
onClick={() => {
setWsPickerOpen(v => !v)
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
)}
{/* Add flow + its error dialog (same package — direct composition). */}
<WorkspacePickFlow
t={t}
open={wsPickerOpen}
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
useDirectoryFlow={useDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
createOnly
addOnly
side="right"
onPick={(workspaceId) => {
setWsPickerOpen(false)
@@ -425,11 +586,11 @@ export function WorkspaceBrowser({
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Rail: the icon is the region's search control. */}
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
<Tooltip label="Search" disabled={wide}>
<Tooltip label={t('search')} disabled={wide}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
aria-label={t('search.sessions.aria')}
tabIndex={wide ? -1 : 0}
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
>
@@ -441,16 +602,17 @@ export function WorkspaceBrowser({
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="Search name, keywords..."
placeholder={t('search.placeholder')}
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
value={query}
onChange={(e) => { setQuery(e.target.value) }}
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
/>
)}
{wide && query !== '' && (
<button
type="button"
className={clsx(css.clearButton, css.wide)}
aria-label="Clear search"
aria-label={t('search.clear')}
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
@@ -461,45 +623,68 @@ export function WorkspaceBrowser({
{/* Always-mounted seat keeps the region's flex slot while the list
itself is wide-only. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
: (
<SessionTree
{wide && (normalizedQuery !== ''
? (
<SearchResults
useSessions={useSessions}
onSessionRename={onSessionRename}
workspaces={workspaces}
startSession={startSession}
open={open}
query={query}
insertSessionBefore={insertSessionBefore}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
workspaces={workspaces}
archivedSessionIds={archivedSessionIds}
query={normalizedQuery}
remote={remoteSearch}
resultLimit={searchResultLimit}
t={t}
/>
))}
)
: groupBy === 'flat'
? (
<FlatList
useSessions={useSessions} open={open} forkSession={forkSession}
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
archivedSessionIds={archivedSessionIds} t={t}
/>
)
: (
<SessionTree
useSessions={useSessions}
onSessionRename={onSessionRename}
onSessionArchive={onSessionArchive}
forkSession={forkSession}
workspaces={workspaces}
archivedSessionIds={archivedSessionIds}
startSession={startSession}
open={open}
insertSessionBefore={insertSessionBefore}
t={t}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
/>
))}
</div>
<Modal
open={renameTarget !== null}
onClose={closeRename}
title="Rename workspace"
closeLabel={t('close')}
title={t('rename.workspace.title')}
footer={(
<>
<Button variant="outline" disabled={renaming} onClick={closeRename}>Cancel</Button>
<Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>Rename</Button>
<Button variant="outline" disabled={renaming} onClick={closeRename}>{t('cancel')}</Button>
<Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>{t('rename')}</Button>
</>
)}
>
<input
className={css.renameInput}
value={renameDraft}
aria-label="Workspace name"
aria-label={t('field.workspaceName')}
autoFocus
disabled={renaming}
onFocus={(e) => { e.target.select() }}
@@ -514,7 +699,7 @@ export function WorkspaceBrowser({
}}
/>
{renameDuplicate && (
<div className={css.renameError} role="alert">A workspace named {renameTrimmed} already exists.</div>
<div className={css.renameError} role="alert">{t('conflict.named', { name: renameTrimmed })}</div>
)}
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
</Modal>
@@ -522,18 +707,19 @@ export function WorkspaceBrowser({
<Modal
open={sessionRenameTarget !== null}
onClose={closeSessionRename}
title="Rename session"
closeLabel={t('close')}
title={t('rename.session.title')}
footer={(
<>
<Button variant="outline" disabled={sessionRenaming} onClick={closeSessionRename}>Cancel</Button>
<Button variant="primary" disabled={sessionRenameBlocked} onClick={confirmSessionRename}>Rename</Button>
<Button variant="outline" disabled={sessionRenaming} onClick={closeSessionRename}>{t('cancel')}</Button>
<Button variant="primary" disabled={sessionRenameBlocked} onClick={confirmSessionRename}>{t('rename')}</Button>
</>
)}
>
<input
className={css.renameInput}
value={sessionRenameDraft}
aria-label="Session name"
aria-label={t('field.sessionName')}
autoFocus
disabled={sessionRenaming}
onFocus={(e) => { e.target.select() }}
@@ -552,25 +738,26 @@ export function WorkspaceBrowser({
<Modal
open={deleteTarget !== null}
onClose={closeDelete}
title="Delete workspace"
closeLabel={t('close')}
title={t('delete.workspace')}
{...deleteTarget === null
? {}
: { description: `This removes “${deleteTarget.title}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.` }}
: { description: t('delete.desc', { name: deleteTarget.title }) }}
footer={(
<>
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
<Button variant="outline" disabled={deleting} onClick={closeDelete}>{t('cancel')}</Button>
<Button
variant="outline"
className={css.deleteAction}
disabled={deleting}
onClick={confirmDelete}
>
Delete workspace
{t('delete.workspace')}
</Button>
</>
)}
>
{deleting && <div className={css.deleteStatus} role="status">Deleting workspace</div>}
{deleting && <div className={css.deleteStatus} role="status">{t('delete.pending')}</div>}
{deleteError !== null && <div className={css.renameError} role="alert">{deleteError}</div>}
</Modal>
</div>
@@ -1,35 +1,10 @@
/* Modal form styles mirror the empty state's path/create modals (same figma
* dialog family: field h44, r22, hairline border, pad 14/7) so the two
* entries stay visually identical. */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
/* The adoption error dialog's footer and message styles; the dialog itself is
* the shared Modal (same figma dialog family as the browser's own dialogs). */
.modalAction {
min-width: 72px;
}
.modalError,
.modalStatus,
.menuStatus {
margin-top: 8px;
font-size: 12px;
@@ -40,7 +15,6 @@
color: var(--dsw-alias-state-error-primary);
}
.modalStatus,
.menuStatus {
color: var(--dsw-alias-label-secondary);
}
@@ -1,40 +1,40 @@
/**
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
* package) and wrapped by WorkspacePicker for the conversation empty-state
* slot registration. Directory picking itself lives in the composed flow
* package's slot occupant (see the contract module doc): this core only
* opens the flow, adopts the picked path, and owns the error surface.
* Workspace pick/add flow. WorkspacePickFlow is the reusable core (menu +
* path error dialog) consumed directly by WorkspaceBrowser (same package) and
* wrapped by WorkspacePicker for the conversation empty-state slot
* registration. Directory picking itself lives in the composed flow package's
* slot occupant (see the contract module doc): this core only opens the flow,
* adopts the picked path, and owns the error surface. Adding a workspace has
* exactly one route — pick a host directory, new or existing — because the
* occupant's own create-folder affordance already covers creating one.
*/
import type { ReactNode, RefObject } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
WorkspaceCreateError,
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
import type {
WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
const CREATE_NEW = '::create-new'
type ModalKind = 'create' | 'folder-error' | null
const ADD_WORKSPACE = '::add-workspace'
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
export interface WorkspacePickFlowProps {
/** The standard locale seat, forwarded by whichever slot entry hosts the flow. */
t: WorkspacePickerProps['t']
/** Popover visibility (anchor button toggle state, owner-local). */
open: boolean
/** The anchor button element — the popover's placement anchor. */
anchorRef?: RefObject<HTMLElement | null> | undefined
/** Selector hook over the workspace list (framework standard hook). */
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */
/** Adopt a picked host directory as a real Workspace. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
/** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */
useDirectoryFlow: SnapshotSelectorHook<boolean>
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
@@ -42,8 +42,8 @@ export interface WorkspaceCreateFlowProps {
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
/** Only show create actions (open folder / create new), hide existing workspaces. */
createOnly?: boolean
/** Only offer the add action, hide existing workspaces. */
addOnly?: boolean
/** Menu opening direction relative to the anchor. */
side?: 'bottom' | 'top' | 'right'
/** Currently active workspace (trailing check in the picker list). */
@@ -51,11 +51,12 @@ export interface WorkspaceCreateFlowProps {
}
/**
* Render the pick menu plus the two create dialogs.
* Render the pick menu plus the adoption error dialog.
* @param props - owner-controlled flow props.
* @returns menu + dialog elements.
*/
export function WorkspaceCreateFlow({
export function WorkspacePickFlow({
t,
open,
anchorRef,
useWorkspaces,
@@ -64,32 +65,25 @@ export function WorkspaceCreateFlow({
renderDirectoryFlow,
onPick,
onClose,
createOnly = false,
addOnly = false,
side = 'bottom',
selectedId,
}: WorkspaceCreateFlowProps) {
}: WorkspacePickFlowProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const getAnchorRect = useCallback(
() => anchorRef?.current?.getBoundingClientRect() ?? null,
[anchorRef],
)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [errorOpen, setErrorOpen] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [flowOpen, setFlowOpen] = useState(false)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const composingRef = useRef(false)
// One picking interaction at a time: while the flow is open (native chooser
// pending, browse dialog up) or its pick is being adopted, every other
// menu action stays disabled — a late outcome must not race a concurrent
// selection or creation.
// selection or adoption.
const flowBusy = flowOpen || pickingFolder
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The occupied hole gates the picking affordance: with no composed flow the
// entry simply is not there (the seam's documented no-flow default). The
@@ -104,27 +98,27 @@ export function WorkspaceCreateFlow({
useEffect(() => {
if (flowOpen && !flowAvailable) setFlowOpen(false)
}, [flowOpen, flowAvailable])
const createEntries: MenuEntry[] = [
...(flowAvailable
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
: []),
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
]
// With workspaces listed, the create actions pin below the scroll region
// (divider + always visible); otherwise they ARE the menu.
const pinCreate = !createOnly && workspaces.length > 0
const items: MenuEntry[] = pinCreate
const addEntries: MenuEntry[] = flowAvailable
? [{ id: ADD_WORKSPACE, label: t('menu.addWorkspace'), icon: <IconPlusOutline16 size={16} />, disabled: flowBusy }]
: []
// With workspaces listed, the add action pins below the scroll region
// (divider + always visible); otherwise it IS the menu.
const pinAdd = !addOnly && workspaces.length > 0
const items: MenuEntry[] = pinAdd
? workspaces.map(workspace => ({
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: flowBusy,
}))
: createEntries
: addEntries
// Nothing listed and nothing to add with (a composition that mounts this
// package without any directory-picker): an empty popover would claim a
// choice that does not exist, so the anchor gesture shows nothing at all.
const menuIsEmpty = items.length === 0
const closeModal = (): void => {
if (creating) return
setModalKind(null)
setErrorOpen(false)
setModalError(null)
}
@@ -134,22 +128,33 @@ export function WorkspaceCreateFlow({
setFlowOpen(false)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
setFolderConflict(
reason instanceof WorkspaceCreateError
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setFlowOpen(false)
setModalKind('folder-error')
setErrorOpen(true)
})
const openLocalFolder = (): void => {
const openDirectoryFlow = useCallback((): void => {
onClose()
setModalKind(null)
setErrorOpen(false)
setModalError(null)
setFolderConflict(false)
setFlowOpen(true)
}
}, [onClose])
// A menu exists to disambiguate between targets. With no workspaces listed
// and the add action the only entry left, the anchor gesture IS that action:
// a one-row popover would cost a click and offer nothing to choose between.
// The owner's open request is consumed the same way selecting the entry
// would consume it (close the popover, raise the flow). An empty list is
// only final once the baseline lands — until then the menu stays up with its
// loading status instead of jumping into a flow the arriving list would have
// made unnecessary; the add-only surface lists nothing and never waits.
const listSettled = addOnly || workspaceSnapshot.phase === 'ready'
const addIsTheOnlyEntry = !pinAdd && listSettled && addEntries.length === 1
// `flowBusy` gates this exactly as it disables the equivalent menu entry: a
// pick still being adopted owns the surface until it settles.
useEffect(() => {
if (open && addIsTheOnlyEntry && !flowBusy) openDirectoryFlow()
}, [open, addIsTheOnlyEntry, flowBusy, openDirectoryFlow])
/** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
const flowOwner: DirectoryFlowOwnerProps = {
@@ -162,55 +167,26 @@ export function WorkspaceCreateFlow({
onCancel: () => { setFlowOpen(false) },
onError: (message) => {
setFlowOpen(false)
setFolderConflict(false)
setModalError(message)
setModalKind('folder-error')
setErrorOpen(true)
},
}
const handleSelect = (id: string): void => {
if (id === OPEN_LOCAL_FOLDER) {
openLocalFolder()
return
}
if (id === CREATE_NEW) {
onClose()
setWorkspaceName('')
setModalError(null)
setModalKind('create')
if (id === ADD_WORKSPACE) {
openDirectoryFlow()
return
}
onPick(id as WorkspaceId)
}
const create = (input: { name: string } | { path: string }): void => {
if (creating) return
setCreating(true)
setModalError(null)
void createWorkspace(input).then((workspace) => {
setCreating(false)
setModalKind(null)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
const message = reason instanceof Error ? reason.message : String(reason)
setModalError(`Workspace creation failed: ${message}`)
setCreating(false)
})
}
const confirmCreate = (): void => {
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
create({ name: normalizedWorkspaceName })
}
}
return (
<>
<Menu
open={open}
open={open && !addIsTheOnlyEntry && !menuIsEmpty}
anchor={null}
items={items}
{...pinCreate ? { footer: createEntries } : {}}
{...pinAdd ? { footer: addEntries } : {}}
selectedId={selectedId}
onSelect={handleSelect}
onClose={onClose}
@@ -218,68 +194,23 @@ export function WorkspaceCreateFlow({
portal
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces</div>}
{open && !addIsTheOnlyEntry && !menuIsEmpty && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
{renderDirectoryFlow(flowOwner)}
<Modal
open={modalKind === 'folder-error'}
open={errorOpen}
onClose={closeModal}
title={folderConflict ? 'A workspace with this name already exists' : 'Couldnt open folder'}
closeLabel={t('close')}
title={t('folderError.title')}
footer={(
<>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>{t('cancel')}</Button>
{/* Retrying needs an occupant to serve the flow; without one the
* button would open a flow nobody can answer or cancel. */}
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openLocalFolder}>Choose again</Button>
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openDirectoryFlow}>{t('folderError.retry')}</Button>
</>
)}
>
<div className={css.modalError} role="alert">
{folderConflict
? 'Choose a folder with a different name.'
: modalError}
</div>
</Modal>
<Modal
open={modalKind === 'create'}
onClose={closeModal}
title="Create a new workspace"
description="The name is used for both the workspace and its new folder."
footer={(
<>
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate}
>
Create workspace
</Button>
</>
)}
>
<input
className={css.modalInput}
value={workspaceName}
placeholder="Workspace name"
aria-label="New workspace name"
autoFocus
disabled={creating}
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(event) => {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
confirmCreate()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">Creating workspace</div>}
{duplicateWorkspaceName && (
<div className={css.modalError} role="alert">A workspace named {normalizedWorkspaceName} already exists.</div>
)}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
<div className={css.modalError} role="alert">{modalError}</div>
</Modal>
</>
)
@@ -301,9 +232,11 @@ export function WorkspacePicker({
createWorkspace,
useDirectoryFlow,
renderSlot,
t,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
<WorkspacePickFlow
t={t}
open={open}
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
@@ -5,26 +5,31 @@
* the whole browsing region (section header, search, grouped/flat session
* list, workspace dialogs). It registers this package's viewing store and
* consumes the shell's two-fact owner share (wide / expandSidebar).
* - WorkspacePicker fills the conversation empty-state hole (menu +
* create dialogs shared with the browser).
* - WorkspacePicker fills the conversation empty-state hole (menu + error
* dialog shared with the browser).
*
* Each registration also declares one **directory-flow hole** (`single`
* kind): the slot a composed picker package's client half fills with its
* picking interaction — a renderless native-chooser driver or an in-app
* browsing dialog. ui-workspace owns the trigger (the "Open local folder…"
* menu entry, shown only while the hole is occupied) and the adoption
* semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
* again); the occupant owns everything between `open` and the picked path.
* browsing dialog. ui-workspace owns the trigger (the "Add workspace…"
* entry, present only while the hole is occupied) and the adoption
* semantics (`createWorkspace({ path })`, the retryable error dialog,
* Choose again); the occupant owns everything between `open` and the picked path,
* including creating a new directory to hand back. That occupant-owned
* creation is why adding a workspace has a single route: an unoccupied hole
* leaves the surface with no add affordance at all.
* Two holes exist because the two menu surfaces are independent slot entries
* and a hole has exactly one declaring entry — they carry the same owner
* contract and the same occupant.
*/
import type { HostObservable, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull the owner SlotMap merges into programs that resolve the
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
@@ -63,7 +68,7 @@ export type DirectoryFlowSlotName =
* Directory-picking share both trigger surfaces consume. Occupancy rides the
* inject face's reserved `hooks` compartment: the renderer binds the source
* into the `useDirectoryFlow` selector hook, so an empty hole hides the
* "Open local folder…" entry reactively and the surface withdraws an open
* "Add workspace…" entry reactively and the surface withdraws an open
* flow whose occupant unloaded mid-interaction (nobody is left to cancel).
*/
export type DirectoryPickingInjected = {
@@ -93,29 +98,48 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
startSession: (workspaceId?: WorkspaceId) => void
/** Open a real Session. */
open: (sessionId: SessionId) => void
/**
* Search current visible conversation messages. The Host fixes the result
* bound; `hasMore` means the query needs narrowing.
*/
searchSessions: (
query: string,
signal: AbortSignal,
) => Promise<{ items: readonly SessionSearchResultItem[]; hasMore: boolean }>
/** Maximum number of merged rows rendered for one search. */
searchResultLimit: number
/** Rename a Session (explicit user title; resolves on host acceptance). */
renameSession: (sessionId: SessionId, title: string) => Promise<void>
/** Fork a Session at its last completed turn and open the child. */
forkSession: (sessionId: SessionId) => void
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
/**
* Archive a Session into the registry-global set: hidden from grouping
* surfaces, log and accounting slot retained. Archiving the current
* session clears the selection into the New Session view state.
*/
archiveSession: (sessionId: SessionId) => Promise<void>
/**
* Reorder a session inside its Workspace account (DOM-insertBefore
* semantics: omitted anchor appends to the end). The view refreshes from
* the Host response/changed frame; failures leave the order unchanged.
*/
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Adopt a picked host directory as a real Workspace before targeting a Session. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
/** Full browser props: shell owner share + viewing store + injected actions + the locale seat. */
export type WorkspaceBrowserProps =
PropsRuntime<'sidebar.workspaces'>
& PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
& Omit<WorkspaceBrowserInjected, 'hooks'>
& DirectoryPickingHooks
& PropsLocale<'workspace'>
/**
* Picker-private injected share. Pick semantics remain in the owner's onPick
@@ -123,17 +147,18 @@ export type WorkspaceBrowserProps =
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = DirectoryPickingInjected & {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Adopt a picked host directory as a real Workspace before targeting a Session. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
}
/**
* Full picker props: the owner share plus the creation callback. The two
* picker holes (blank-session hero / New-Session view) share one owner
* currency, so one composed type serves both registrations.
* Full picker props: the owner share plus the creation callback and the
* locale seat. The two picker holes (blank-session hero / New-Session view)
* share one owner currency, so one composed type serves both registrations.
*/
export type WorkspacePickerProps =
PropsRuntime<'conversation.hero.workspace'>
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
& Omit<WorkspacePickerInjected, 'hooks'>
& DirectoryPickingHooks
& PropsLocale<'workspace'>
@@ -11,15 +11,29 @@
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { createWorkspaceViewStore } from './stores.ts'
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
import { en, zh, type WorkspaceKey } from './locales.ts'
export type {
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected,
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
} from './contract/slots.ts'
export type { WorkspaceKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The workspace browsing region and pick/create flow copy. */
workspace: WorkspaceKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'workspace'
/**
* Required services (cordis fiber inject). The target slots are declared by
@@ -29,7 +43,7 @@ export type {
* provides a waitable service. apply therefore registers via
* declaration-aware deferral instead of assuming order.
*/
export const inject = ['slots', 'sessions', 'workspaces']
export const inject = ['slots', 'sessions', 'workspaces', 'locale']
/**
* Register the browser and picker once their slot declarations are on the
@@ -38,6 +52,14 @@ export const inject = ['slots', 'sessions', 'workspaces']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => {
const result = await ctx.sessions.search(query, signal)
if (!result.ok) throw new Error(result.error.message)
return result.value
}
// Stable per-surface occupancy sources (the renderer's hook cache keys by
// source identity): true while the surface's directory-flow hole is filled.
const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable<boolean> => ({
@@ -51,6 +73,8 @@ export function apply(ctx: ClientContext): void {
// the runtime's shared action (recent-Workspace projection inside).
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
searchSessions,
searchResultLimit: ctx.sessions.searchResultLimit,
renameSession: async (sessionId, title) => {
// Row → session-face hop: rename is a per-session verb (ISession), not
// a list-service verb; the binding resolves any listed session.
@@ -59,8 +83,16 @@ export function apply(ctx: ClientContext): void {
const result = await session.rename(title)
if (!result.ok) throw new Error(result.error.message)
},
forkSession: (sessionId) => {
ctx.sessions.fork({ sessionId, increaseTitle: true })
.then((childId) => { ctx.sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the current selection.
})
},
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
@@ -86,6 +118,7 @@ export function apply(ctx: ClientContext): void {
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
store: createWorkspaceViewStore(),
inject: browserInjected,
locale: NS,
},
WorkspaceBrowser,
)),
@@ -95,6 +128,7 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.hero.workspace',
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
inject: pickerInjected,
locale: NS,
},
WorkspacePicker,
)),
@@ -0,0 +1,118 @@
/**
* `workspace` namespace dictionaries: the browsing region (section header,
* search, tree rows, dialogs) and the pick/add flow. Runtime failure
* messages (wire error strings) pass through untranslated by policy.
*/
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'group.ungrouped': '未分组',
'session.new': '新会话',
'section.workspaces': '工作区',
'section.sessions': '会话',
'groupBy.label': '分组方式',
'groupBy.workspace': '按工作区',
'groupBy.flat': '单列表',
'empty.none': '暂无会话',
'empty.noMatches': '无匹配结果',
'workspace.add': '添加工作区',
'search.sessions.aria': '搜索会话',
'search.placeholder': '搜索名称、关键词…',
'search.clear': '清除搜索',
'search.results.aria': '搜索结果',
'search.pending': '正在搜索会话历史…',
'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。',
'search.noMatches': '无匹配会话',
'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
'menu.addWorkspace': '添加工作区…',
'picker.loading': '正在加载工作区…',
'conflict.named': '已存在名为“{name}”的工作区。',
'folderError.title': '无法打开文件夹',
'folderError.retry': '重新选择',
'rename': '重命名',
'rename.workspace.title': '重命名工作区',
'rename.session.title': '重命名会话',
'field.workspaceName': '工作区名称',
'field.sessionName': '会话名称',
'delete.workspace': '删除工作区',
'delete.desc': '将把“{name}”从工作区列表中移除。文件夹与会话记录会保留,其会话将显示在“未分组”下。',
'delete.pending': '正在删除工作区…',
'menu.fork': '分叉会话',
'menu.archiveSession': '归档会话',
'sessions.count.one': '{n} 个会话',
'sessions.count.other': '{n} 个会话',
'actions.workspace.aria': '工作区“{name}”的操作',
'actions.session.aria': '会话“{name}”的操作',
'actions.newSession.aria': '在“{name}”中新建会话',
'status.running': '进行中',
'status.idle': '空闲',
'status.waitingApproval': '等待审批',
'hover.created': '创建于 {time}',
'hover.copied': '已复制',
'date.ymd': '{y}年{m}月{d}日',
'time.now': '刚刚',
'time.minutes': '{n}分钟',
'time.hours': '{n}小时',
'time.days': '{n}天',
'time.months': '{n}个月',
'time.years': '{n}年',
'time.ago': '{t}前',
} satisfies Record<string, string>
/** The workspace namespace key union. */
export type WorkspaceKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'group.ungrouped': 'Ungrouped',
'session.new': 'New Session',
'section.workspaces': 'Workspaces',
'section.sessions': 'Sessions',
'groupBy.label': 'Group by',
'groupBy.workspace': 'WorkSpace',
'groupBy.flat': 'In one list',
'empty.none': 'No sessions yet',
'empty.noMatches': 'No matches',
'workspace.add': 'Add workspace',
'search.sessions.aria': 'Search sessions',
'search.placeholder': 'Search name, keywords...',
'search.clear': 'Clear search',
'search.results.aria': 'Search results',
'search.pending': 'Searching session history…',
'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.',
'search.noMatches': 'No matching sessions',
'search.hasMore': 'Showing the first {n} results. Narrow your search.',
'menu.addWorkspace': 'Add workspace…',
'picker.loading': 'Loading workspaces…',
'conflict.named': 'A workspace named “{name}” already exists.',
'folderError.title': 'Couldnt open folder',
'folderError.retry': 'Choose again',
'rename': 'Rename',
'rename.workspace.title': 'Rename workspace',
'rename.session.title': 'Rename session',
'field.workspaceName': 'Workspace name',
'field.sessionName': 'Session name',
'delete.workspace': 'Delete workspace',
'delete.desc': 'This removes “{name}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.',
'delete.pending': 'Deleting workspace…',
'menu.fork': 'Fork session',
'menu.archiveSession': 'Archive session',
'sessions.count.one': '{n} session',
'sessions.count.other': '{n} sessions',
'actions.workspace.aria': 'Workspace actions for {name}',
'actions.session.aria': 'Session actions for {name}',
'actions.newSession.aria': 'New session in {name}',
'status.running': 'Running',
'status.idle': 'Idle',
'status.waitingApproval': 'Waiting for approval',
'hover.created': 'Created {time}',
'hover.copied': 'Copied',
'date.ymd': '{y}-{m}-{d}',
'time.now': 'now',
'time.minutes': '{n}min',
'time.hours': '{n}h',
'time.days': '{n}d',
'time.months': '{n}mo',
'time.years': '{n}y',
'time.ago': '{t} ago',
} satisfies Record<WorkspaceKey, string>
@@ -24,6 +24,64 @@
background: var(--dsw-alias-interactive-bg-active);
}
.searchResultRow {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
min-height: 62px;
box-sizing: border-box;
border: none;
border-radius: 8px;
padding: 7px 8px;
background: transparent;
cursor: pointer;
text-align: left;
color: var(--dsw-alias-label-primary);
}
.searchResultRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchResultRow.selected {
background: var(--dsw-alias-interactive-bg-active);
}
.searchResultHeading {
display: flex;
align-items: center;
min-width: 0;
}
.searchResultTitle {
min-width: 0;
margin-left: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 20px;
}
.searchResultWorkspace,
.searchResultSnippet {
margin-left: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 17px;
}
.searchResultWorkspace {
color: var(--dsw-alias-label-tertiary);
}
.searchResultSnippet {
color: var(--dsw-alias-label-secondary);
}
/* Two-line row: the leading slot (folder/chevron), title, and trailing
actions all top-align on the 20px first text line (figma cell) — content
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */
@@ -39,9 +97,7 @@
height: 20px;
}
/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px
gap to the title — the slots butt together, so the row gap is zeroed and
the title carries its own margins. */
/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
.sessionRow {
height: 34px;
gap: 0;
@@ -177,7 +233,7 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Drag reorder insert line (workspace-group roots): 2px accent above or
/* Drag reorder insert line (workspace-group session rows): 2px accent above or
below the hovered row, drawn with box-shadow so no layout shift. */
.sessionRow.dropBefore {
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
@@ -242,33 +298,9 @@
color: var(--dsw-alias-label-primary);
}
/* Session expand twist occupies the leading 16px slot; keep a spacer when absent
so titles align across sibling rows. Duplicates the .iconButton reset instead
of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which
left the raw UA button box showing. */
.twist {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 20px;
border: none;
border-radius: 4px;
padding: 0;
background: transparent;
cursor: pointer;
}
.twist:hover {
color: var(--dsw-alias-label-primary);
}
/* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph
stays one step darker (tertiary, #81858C) per the cell spec. Declared last
to win over the composed .iconButton color. */
.chevron,
.twist {
/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step
darker (tertiary, #81858C) per the cell spec. */
.chevron {
color: var(--dsw-alias-label-caption);
}
@@ -2,46 +2,66 @@
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename/Delete and session Rename; the session and workspace
* hover cards are suppressed while a menu is open.
* except workspace Rename/Delete and session Rename/Fork/Archive; the session
* and workspace hover cards are suppressed while a menu is open.
*/
import { useState } from 'react'
import clsx from 'clsx'
import {
HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16,
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
HoverCard, IconArchiveOutline20, IconBranchOutline16, IconEditOutline16,
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import type { GroupNode, SessionNode } from '../tree.ts'
import { formatRelativeTime } from '../tree.ts'
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
import { relativeTime } from '../tree.ts'
import css from './Rows.module.css'
/** Indent step per tree level: one 16px slot (figma session cell). */
const INDENT_STEP = 16
/** The standard locale seat, prop-passed from the browser root. */
type RowTranslate = WorkspaceBrowserProps['t']
const SESSION_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
{ id: 'delete', label: 'Delete session', icon: <IconTrashOutline16 />, danger: true },
]
/** Row display title: blank rows show the localized New Session label. */
function displayTitle(node: SessionNode, t: RowTranslate): string {
return node.blank ? t('session.new') : node.title
}
const WORKSPACE_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true },
]
/** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */
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 (no "now ago"). */
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 (the message
* clock pattern): `toLocaleString` would follow the browser language, not the
* app locale, and produce mixed-language text after a switch.
*/
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, full directory path, absolute creation time. */
function WorkspaceHoverContent({ label, cwd, createdAt }: {
function WorkspaceHoverContent({ label, cwd, createdAt, t }: {
label: string
cwd: string | undefined
createdAt: number
t: RowTranslate
}) {
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{label}</div>
<div className={css.hoverPath}>{cwd}</div>
<div className={css.hoverTime}>{`Created ${new Date(createdAt).toLocaleString()}`}</div>
<div className={css.hoverTime}>{createdLabel(createdAt, t)}</div>
</div>
)
}
@@ -54,19 +74,27 @@ function WorkspaceHoverContent({ label, cwd, createdAt }: {
* @param props.group - derived group node.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace.
* @param props.t - the browser root's locale seat.
* @returns the row element.
*/
export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
actions?: { rename: () => void; delete: () => void } | undefined
t: RowTranslate
}) {
const row = group
// The ungrouped bucket has no workspace title: its label is dictionary copy.
const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label
const active = group.expanded && group.containsCurrent
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
const count = t(row.sessionCount === 1 ? 'sessions.count.one' : 'sessions.count.other', { n: row.sessionCount })
const [menuOpen, setMenuOpen] = useState(false)
const workspaceMenuItems = [
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
{ id: 'delete', label: t('delete.workspace'), icon: <IconTrashOutline16 />, danger: true },
]
const ownRow = (
<div
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
@@ -81,7 +109,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</span>
<span className={css.projectText}>
<span className={css.title}>{row.label}</span>
<span className={css.title}>{label}</span>
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
@@ -89,12 +117,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={WORKSPACE_MENU_ITEMS}
items={workspaceMenuItems}
onSelect={(id) => {
setMenuOpen(false)
// Unknown ids leave before the dispatch: a future menu row must
// not inherit the destructive branch as an else fallback.
/* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */
/* v8 ignore next -- workspaceMenuItems carries exactly these two rows today. */
if (id !== 'rename' && id !== 'delete') return
if (id === 'rename') actions.rename()
else actions.delete()
@@ -105,7 +133,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
<button
type="button"
className={css.iconButton}
aria-label={`Workspace actions for ${row.label}`}
aria-label={t('actions.workspace.aria', { name: label })}
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
>
<IconEllipsisOutline16 />
@@ -116,7 +144,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
<button
type="button"
className={css.iconButton}
aria-label={`New session in ${row.label}`}
aria-label={t('actions.newSession.aria', { name: label })}
onClick={(e) => { e.stopPropagation(); onCreate() }}
>
<IconPlusOutline16 />
@@ -129,26 +157,31 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
return (
<HoverCard
anchor={ownRow}
content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} />}
content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} t={t} />}
disabled={menuOpen}
copyText={row.cwd}
copyLabel={t('copy')}
copiedLabel={t('hover.copied')}
/>
)
}
/** Session status presentation; approval waiting outranks the underlying running state. */
function sessionStatus(node: SessionNode): { state: StateDotState; label: string } {
if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' }
if (node.running) return { state: 'ongoing', label: 'Running' }
return { state: 'done', label: 'Idle' }
function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } {
if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') }
if (node.running) return { state: 'ongoing', label: t('status.running') }
return { state: 'done', label: t('status.idle') }
}
/** Hover-card body: full title, relative time, and approval/running/idle status. */
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
const status = sessionStatus(node)
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
const status = sessionStatus(node, t)
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{node.title}</div>
<div className={css.hoverTime}>{`${formatRelativeTime(node.updatedAt, now)} ago`}</div>
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
{/* Same placeholder rule as the row's trailing cell: no timestamp
before the first prompt. */}
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
<div className={css.hoverStatus}>
<StateDot state={status.state} />
<span>{status.label}</span>
@@ -158,7 +191,7 @@ function SessionHoverContent({ node, now }: { node: SessionNode; now: number })
}
/**
* Root-row drag wiring supplied by the group owner (workspace groups only).
* Session-row drag wiring supplied by the group owner (workspace groups only).
* `drop` reports the half of the row the pointer released on: 'before'
* inserts above this row, 'after' below it (the owner resolves the anchor).
*/
@@ -175,6 +208,41 @@ export interface RowDragProps {
end: () => void
}
/**
* One flat search result: title, Workspace context, and optional content
* excerpt. Search navigation opens the session only; it does not address an
* event inside the conversation.
* @param props.result - merged local/content search row.
* @param props.currentId - selected session id.
* @param props.onOpen - open the selected session.
* @returns the result button.
*/
export function SearchResultItem({ result, currentId, onOpen }: {
result: SearchResultNode
currentId: string | undefined
onOpen: (id: SearchResultNode['id']) => void
}) {
const selected = result.id === currentId
return (
<button
type="button"
className={clsx(css.searchResultRow, selected && css.selected)}
role="treeitem"
aria-selected={selected}
onClick={() => { onOpen(result.id) }}
>
<span className={css.searchResultHeading}>
<span className={css.slot}>{result.running && <StateDot state="ongoing" />}</span>
<span className={css.searchResultTitle}>{result.title}</span>
</span>
<span className={css.searchResultWorkspace}>{result.workspace}</span>
{result.snippet !== undefined && (
<span className={css.searchResultSnippet}>{result.snippet}</span>
)}
</button>
)
}
/** Pointer-position half of a row (insert line above or below). */
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
const rect = e.currentTarget.getBoundingClientRect()
@@ -182,41 +250,49 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
}
/**
* One session subtree: the node's own 34px row (indent by depth, expand
* twist when it has children, status dot, relative time) plus its visible
* children, recursively — the component tree mirrors the derived tree.
* One top-level 34px session row: status dot (approval waiting outranks
* running), title, relative time, and the row actions menu.
* @param props.node - derived session node.
* @param props.depth - 0 = directly under the group header.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onRename - rename a session by id and current title.
* @param props.onToggle - unfold/fold a subtree by id.
* @param props.drag - optional root-row drag wiring.
* @param props.flat - omit tree indentation controls for a flat list.
* @returns the node's row followed by its children.
* @param props.onRename - open the session rename dialog (id + current title).
* @param props.onFork - fork a session at its last completed turn.
* @param props.onArchive - archive a session by id.
* @param props.drag - optional draggable-row wiring.
* @param props.t - the browser root's locale seat.
* @returns the session row.
*/
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
node: SessionNode
depth: number
currentId: string | undefined
now: number
onOpen: (id: SessionNode['id']) => void
/** Open the browser-owned session rename dialog (row menu action). */
onRename: (id: SessionNode['id'], currentTitle: string) => void
onToggle: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group roots outside search). */
/** Fork a session at its last completed turn (row menu action). */
onFork: (id: SessionNode['id']) => void
/** Archive this session (row menu action; commits without a dialog). */
onArchive: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group sessions outside search). */
drag?: RowDragProps | undefined
/** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */
flat?: boolean
t: RowTranslate
}) {
const row = node
const title = displayTitle(node, t)
const selected = node.id === currentId
const status = sessionStatus(node)
const status = sessionStatus(node, t)
const [menuOpen, setMenuOpen] = useState(false)
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
// the title): both slots are always reserved so titles align whether or not
// the twist/dot is lit. Extra depth rides the left padding.
// Archive replaces the former Delete placeholder: it hides the row through
// the registry-global archive set and never touches the session log, so it
// is not styled as destructive and needs no confirmation dialog.
const sessionMenuItems = [
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
{ id: 'fork', label: t('menu.fork'), icon: <IconBranchOutline16 /> },
// 20-native glyph in the menu's 16px icon slot (Menu.module.css .itemIcon).
{ id: 'archive', label: t('menu.archiveSession'), icon: <IconArchiveOutline20 size={16} /> },
]
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
const ownRow = (
<div
className={clsx(
@@ -225,8 +301,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
)}
role="treeitem"
aria-selected={selected}
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
onClick={() => { onOpen(node.id) }}
draggable={drag !== undefined}
onDragStart={drag === undefined
@@ -252,18 +326,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
drag.drop(rowHalf(e))
}}
>
{row.hasChildren && !flat
? (
<button
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
: null}
<span className={css.slot}>
{status.state !== 'done' && (
<>
@@ -272,52 +334,49 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
</>
)}
</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
<span className={css.rowActions}>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={SESSION_MENU_ITEMS}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
}}
portal
closeOnPointerLeave
anchor={(
<button
type="button"
className={css.iconButton}
aria-label={`Session actions for ${row.title}`}
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
>
<IconEllipsisOutline16 />
</button>
)}
/>
</span>
<span className={css.title}>{title}</span>
{/* A blank New Session row is a provisional placeholder: nothing has
happened in it yet, so a "now" timestamp and the row verbs
(rename/fork/archive) would all act on content that does not
exist — both trailing cells stay off until the first prompt. */}
{!row.blank && <span className={css.time}>{timeLabel(row.updatedAt, now, t)}</span>}
{!row.blank && (
<span className={css.rowActions}>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={sessionMenuItems}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename(node.id, row.title)
if (id === 'fork') onFork(node.id)
if (id === 'archive') onArchive(node.id)
}}
portal
closeOnPointerLeave
anchor={(
<button
type="button"
className={css.iconButton}
aria-label={t('actions.session.aria', { name: title })}
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
>
<IconEllipsisOutline16 />
</button>
)}
/>
</span>
)}
</div>
)
return (
<>
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} />}
disabled={menuOpen || drag?.active === true}
/>
{node.children.map(child => (
<SessionNodeItem
key={child.id}
node={child}
depth={depth + 1}
currentId={currentId}
now={now}
onOpen={onOpen}
onRename={onRename}
onToggle={onToggle}
/>
))}
</>
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} t={t} />}
disabled={menuOpen || drag?.active === true}
copyText={row.blank ? undefined : row.title}
copyLabel={t('copy')}
copiedLabel={t('hover.copied')}
/>
)
}
+185 -177
View File
@@ -3,7 +3,9 @@
* Unassigned Sessions trail under Ungrouped; only the selected blank Session
* remains visible.
*/
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionId, SessionListState, SessionSearchResultItem, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for Sessions outside every Workspace. */
export const UNGROUPED_KEY = ''
@@ -11,22 +13,20 @@ export const UNGROUPED_KEY = ''
/** Display label for the ungrouped bucket row. */
export const UNGROUPED_LABEL = 'Ungrouped'
/** One session node of a group's visible tree (34px row; children render indented one step). */
/** One top-level session row in a group or the flat list. */
export interface SessionNode {
id: SessionId
/** Stored display title; the renderer substitutes the localized New Session label for blank rows. */
title: string
/** Visible children, already expansion/search-filtered (empty when folded). */
children: readonly SessionNode[]
/** The session HAS children in the data (the twist renders even while folded). */
hasChildren: boolean
expanded: boolean
/** The provisional blank session (renderer shows the localized New Session title). */
blank: boolean
/** The runtime Session list reports a pending approval request for this Session. */
waitingApproval: boolean
running: boolean
updatedAt: number
}
/** One workspace group section: header row facts + the visible session tree. */
/** One workspace group section: header row facts + visible top-level session rows. */
export interface GroupNode {
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
key: string
@@ -41,15 +41,28 @@ export interface GroupNode {
expanded: boolean
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
containsCurrent: boolean
/** Visible roots (empty while the group is folded). */
/** Visible session rows (empty while the group is folded). */
sessions: readonly SessionNode[]
}
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
/** One flat search row combining list metadata with an optional content match. */
export interface SearchResultNode {
id: SessionId
title: string
workspace: string
running: boolean
snippet?: string
}
/** Bounded merged search projection plus the refine-query hint bit. */
export interface SearchResultSet {
items: readonly SearchResultNode[]
hasMore: boolean
}
/** Viewing state consumed by the derivation. */
export interface TreeView {
expandedProjects: readonly string[]
expandedSessions: readonly string[]
query: string
}
interface Group {
@@ -58,9 +71,7 @@ interface Group {
cwd: string | undefined
createdAt: number | undefined
label: string
summaries: Map<SessionId, SessionSummary>
roots: SessionId[]
children: Map<SessionId, SessionId[]>
sessions: SessionSummary[]
}
/**
@@ -81,17 +92,28 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
return a.id < b.id ? -1 : 1
}
/** Ordinary sessions are visible; among blank sessions, only the current one is visible. */
function sessionVisible(session: SessionSummary, current: SessionId | undefined): boolean {
return !session.blank || session.id === current
/**
* Ordinary sessions are visible; among blank sessions, only the current one
* is visible. Subagent children use their parent header catalog; archived
* sessions are visible nowhere, while their accounting slots remain so
* unarchiving restores position.
*/
function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet<SessionId>): boolean {
return session.origin !== 'subagent'
&& !archived.has(session.id)
&& (!session.blank || session.id === current)
}
/** A blank session is the selected Workspace's provisional New Session row. */
/**
* A blank session is the selected Workspace's provisional New Session row;
* its canonical title never enters search (blank rows are query-excluded)
* and the renderer localizes its display label.
*/
function sessionTitle(session: SessionSummary): string {
return session.blank ? 'New Session' : session.displayTitle
}
/** Build one group's parent/child tree from an ordered member list. */
/** Build one group without projecting session lineage into presentation. */
function buildGroup(
key: string,
workspaceId: WorkspaceId | undefined,
@@ -101,54 +123,11 @@ function buildGroup(
members: readonly SessionSummary[],
order: 'account' | 'recency',
): Group {
const summaries = new Map(members.map(m => [m.id, m]))
const children = new Map<SessionId, SessionId[]>()
const roots: SessionSummary[] = []
for (const m of members) {
// A session is a tree child only when its parent lives in the same
// group; cross-group or unknown parents degrade to group roots.
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
const kids = children.get(m.parentId)
if (kids === undefined) children.set(m.parentId, [m.id])
else kids.push(m.id)
} else {
roots.push(m)
}
}
// Workspace order is the member iteration order (workspace.sessionIds), so
// attached groups keep insertion order; Ungrouped sorts by recency.
if (order === 'recency') {
roots.sort(byRecency)
for (const kids of children.values()) {
kids.sort((a, b) => {
const sa = summaries.get(a)
const sb = summaries.get(b)
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
if (sa === undefined || sb === undefined) return 0
return byRecency(sa, sb)
})
}
}
const rootIds = roots.map(r => r.id)
// parentId cycles (host bug) leave members unreachable from any root;
// surface them as extra roots — the flatten walk's visited set stops
// loops. Each node sits in at most one kids list and roots have no
// in-group parent, so the scan pushes every reachable node exactly once.
const reachable = new Set<SessionId>(rootIds)
const stack = [...rootIds]
while (stack.length > 0) {
const top = stack.pop()
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
if (top === undefined) break
for (const kid of children.get(top) ?? []) {
reachable.add(kid)
stack.push(kid)
}
}
for (const m of members) {
if (!reachable.has(m.id)) rootIds.push(m.id)
}
return { key, workspaceId, cwd, createdAt, label, summaries, roots: rootIds, children }
const sessions = [...members]
// Workspace order is workspace.sessionIds; only Ungrouped lacks an account
// order and therefore falls back to recency.
if (order === 'recency') sessions.sort(byRecency)
return { key, workspaceId, cwd, createdAt, label, sessions }
}
/**
@@ -156,7 +135,11 @@ function buildGroup(
* order, with members resolved from sessionIds in their stored order. Sessions
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
*/
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
function groupByWorkspace(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archived: ReadonlySet<SessionId>,
): Group[] {
const groups: Group[] = []
const accounted = new Set<SessionId>()
for (const workspace of workspaces) {
@@ -165,7 +148,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
const summary = list.byId[id]
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
accounted.add(id)
if (!sessionVisible(summary, list.current)) continue
if (!sessionVisible(summary, list.current, archived)) continue
members.push(summary)
}
groups.push(buildGroup(
@@ -176,127 +159,64 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
const stray = list.ids
.map(id => list.byId[id])
.filter((s): s is SessionSummary =>
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current))
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
if (stray.length > 0) {
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
}
return groups
}
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
function sessionNode(s: SessionSummary): SessionNode {
return {
id: s.id,
title: sessionTitle(s),
children,
hasChildren,
expanded,
blank: s.blank,
waitingApproval: s.waitingApproval,
running: s.running,
updatedAt: s.updatedAt,
}
}
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return null
const kids = g.children.get(id) ?? []
const expanded = expandedSessions.has(id)
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
return sessionNode(s, children, kids.length > 0, expanded)
}
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/** Matched sessions plus their ancestor chains (forced visible under search). */
function searchVisible(g: Group, q: string): Set<SessionId> {
const visible = new Set<SessionId>()
for (const m of g.summaries.values()) {
if (!sessionTitle(m).toLowerCase().includes(q)) continue
let cur: SessionSummary | undefined = m
while (cur !== undefined && !visible.has(cur.id)) {
visible.add(cur.id)
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
}
}
return visible
}
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id) || !visible.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return null
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
return sessionNode(s, children, kids.length > 0, kids.length > 0)
}
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/**
* Derive the nested workspace browser group structure.
* Derive the workspace browser groups with every session as a top-level row.
*
* Normal mode: every group shows; sessions populate under expanded groups,
* descending only into expanded sessions. Search mode (non-blank query,
* case-insensitive display-title substring): expansion state is ignored —
* matched sessions and their ancestor chains are forced visible, groups
* without a display-title or label hit are dropped, and a label-only hit
* keeps the bare group header. Blank sessions are excluded everywhere.
* Every group shows; sessions populate under expanded groups, preserving
* Host account order. Blank sessions are excluded except for the selected
* provisional New Session row; archived sessions are excluded everywhere.
* Content search lives outside this derivation
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param view - local expansion arrays and search query.
* @param archivedSessionIds - registry-global archive set.
* @param view - local expansion arrays.
* @returns group sections in render order.
*/
export function deriveGroups(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archivedSessionIds: readonly SessionId[],
view: TreeView,
): GroupNode[] {
const q = view.query.trim().toLowerCase()
const archived = new Set(archivedSessionIds)
const expandedProjects = new Set(view.expandedProjects)
const expandedSessions = new Set(view.expandedSessions)
const currentGroup = list.current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces)) {
if (q === '') {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? buildVisible(g, expandedSessions) : [],
})
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
expanded: visible.size > 0,
containsCurrent: g.key === currentGroup,
sessions: buildSearch(g, visible),
})
}
for (const g of groupByWorkspace(list, workspaces, archived)) {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.sessions.length,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? g.sessions.map(sessionNode) : [],
})
}
return groups
}
@@ -304,41 +224,129 @@ export function deriveGroups(
/**
* Derive the flat session list ("In one list" mode): every session — fork
* children included — as a top-level row, strictly newest-first. No grouping,
* no parent/child adjacency; rows reuse SessionNode with children always
* empty so the renderer stays branch-free. Search mode filters by
* case-insensitive display-title substring.
* no parent/child adjacency. Content search lives outside this derivation
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot.
* @param view - the search query (expansion state does not apply).
* @param archivedSessionIds - registry-global archive set.
* @returns flat rows in render order.
*/
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
const q = view.query.trim().toLowerCase()
export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
const archived = new Set(archivedSessionIds)
const rows: SessionSummary[] = []
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined || !sessionVisible(s, list.current)) continue
if (q !== '' && !sessionTitle(s).toLowerCase().includes(q)) continue
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
rows.push(s)
}
rows.sort(byRecency)
return rows.map(s => sessionNode(s, [], false, false))
return rows.map(sessionNode)
}
/** Relative-time bucket of a session row's trailing label. */
export type RelativeTimeUnit = 'now' | 'minutes' | 'hours' | 'days' | 'months' | 'years'
/** Structured relative time: the bucket plus its magnitude (0 for 'now'). */
export interface RelativeTime {
unit: RelativeTimeUnit
n: number
}
/**
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
* Merge immediate title/Workspace substring matches with ranked Host content
* matches. Local rows lead newest-first, content-only rows retain backend
* order, and duplicate sessions receive the backend snippet in place.
* @param list - session metadata authority.
* @param workspaces - Workspace membership and display labels.
* @param query - caller text; surrounding whitespace is ignored.
* @param archivedSessionIds - registry-global archive set (members never match).
* @param content - ranked Host content-search page.
* @param limit - protocol-owned maximum merged row count.
* @returns bounded deduplicated flat rows and a refine-query hint bit.
*/
export function deriveSearchResults(
list: SessionListState,
workspaces: readonly WorkspaceView[],
query: string,
archivedSessionIds: readonly SessionId[],
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
limit: number,
): SearchResultSet {
const q = query.trim().toLowerCase()
if (q === '') return { items: [], hasMore: false }
const archived = new Set(archivedSessionIds)
const workspaceBySession = new Map<SessionId, string>()
for (const workspace of workspaces) {
for (const sessionId of workspace.sessionIds) {
if (!workspaceBySession.has(sessionId)) workspaceBySession.set(sessionId, workspace.title)
}
}
const labelOf = (summary: SessionSummary): string =>
workspaceBySession.get(summary.id) ?? projectLabel(summary.cwd)
const contentBySession = new Map<SessionId, SessionSearchResultItem>()
for (const item of content.items) {
if (!contentBySession.has(item.sessionId)) contentBySession.set(item.sessionId, item)
}
const local: SessionSummary[] = []
for (const id of list.ids) {
const summary = list.byId[id]
// Blank placeholders never match a query (their canonical title displays
// localized, so matching it would tie search to one language).
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue
if (
sessionTitle(summary).toLowerCase().includes(q)
|| labelOf(summary).toLowerCase().includes(q)
) {
local.push(summary)
}
}
local.sort(byRecency)
const ordered: SessionSummary[] = []
const included = new Set<SessionId>()
const include = (summary: SessionSummary): void => {
if (included.has(summary.id)) return
included.add(summary.id)
ordered.push(summary)
}
for (const summary of local) include(summary)
for (const item of content.items) {
const summary = list.byId[item.sessionId]
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary)
}
return {
items: ordered.slice(0, limit).map((summary) => {
const match = contentBySession.get(summary.id)
return {
id: summary.id,
title: sessionTitle(summary),
workspace: labelOf(summary),
running: summary.running,
...match === undefined ? {} : { snippet: match.snippet },
}
}),
hasMore: content.hasMore || ordered.length > limit,
}
}
/**
* Compact relative time for session rows, as a structured bucket the
* renderer localizes ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en).
* @param updatedAt - epoch ms of the session's last activity.
* @param now - current epoch ms (injected for pure rendering).
* @returns the row's trailing time label.
* @returns the row's trailing time bucket and magnitude.
*/
export function formatRelativeTime(updatedAt: number, now: number): string {
export function relativeTime(updatedAt: number, now: number): RelativeTime {
const MIN = 60_000
const HOUR = 3_600_000
const DAY = 86_400_000
const diff = Math.max(0, now - updatedAt)
if (diff < MIN) return 'now'
if (diff < HOUR) return `${Math.floor(diff / MIN)}min`
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`
if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d`
if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo`
return `${Math.floor(diff / (365 * DAY))}y`
if (diff < MIN) return { unit: 'now', n: 0 }
if (diff < HOUR) return { unit: 'minutes', n: Math.floor(diff / MIN) }
if (diff < DAY) return { unit: 'hours', n: Math.floor(diff / HOUR) }
if (diff < 30 * DAY) return { unit: 'days', n: Math.floor(diff / DAY) }
if (diff < 365 * DAY) return { unit: 'months', n: Math.floor(diff / (30 * DAY)) }
return { unit: 'years', n: Math.floor(diff / (365 * DAY)) }
}
@@ -15,10 +15,10 @@ export const name = 'client-ui-workspace-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin registering one presentational
* component into two host-declared slots its inject face is two stateless
* RPC wrappers plus a create-and-open call; it emits no cordis events and
* owns no cross-plugin mutable state.
* No runtime invariant: a pure-consumer plugin registering presentational
* components into two host-declared slots plus its locale dictionaries — its
* inject face is stateless RPC wrappers plus a create-and-open call; it
* emits no cordis events and owns no cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}