feat(client): improve workspace session browsing

This commit is contained in:
_Kerman
2026-08-11 13:20:16 +08:00
parent c172faed37
commit 8e0cb2bdba
19 changed files with 198 additions and 62 deletions

View File

@@ -194,14 +194,14 @@
position: relative;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
/* Bottom fade: compact overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: var(--dsh-session-list-edge-inset);
bottom: 0;
height: 72px;
height: 24px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
}
@@ -229,9 +229,9 @@
- var(--dsh-session-list-scrollbar-width)
- var(--dsh-session-list-scrollbar-offset)
);
/* Clears the 72px bottom fade overlay: at scroll end the last row sits
/* Clears the compact bottom fade overlay: at scroll end the last row sits
above the gradient instead of under it. */
padding-bottom: 48px;
padding-bottom: 16px;
scrollbar-gutter: stable;
}
@@ -258,6 +258,24 @@
margin-top: 4px;
}
.sessionOverflowButton {
width: 100%;
height: 30px;
border: none;
border-radius: 8px;
padding: 0 12px 0 28px;
background: transparent;
cursor: pointer;
text-align: left;
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
}
.sessionOverflowButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);

View File

@@ -1,6 +1,6 @@
/**
* The workspace/session browsing region filling the sidebar shell's
* `sidebar.workspaces` hole: section header (title + group-by + add
* `sidebar.workspaces` hole: section header (title + view options + 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 / add workspace), each requesting shell expansion
@@ -19,7 +19,7 @@ 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 type { SessionNode, SessionOrderBy } from './tree.ts'
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
@@ -34,6 +34,8 @@ const EXPAND_SLIDE_MS = 300
const SEARCH_DEBOUNCE_MS = 250
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
const SEARCH_QUERY_MAX_CODE_UNITS = 500
/** Session rows visible per Workspace before the local overflow control. */
const COLLAPSED_SESSION_LIMIT = 6
/** Keep controlled input and RPC payload inside the session.search wire contract. */
function sanitizeSearchQuery(value: string): string {
@@ -51,10 +53,12 @@ function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
}
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
function GroupByMenu({ groupBy, onPick, t }: {
/** Grouping and ordering menu; own open state so it resets with the wide chrome. */
function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
groupBy: 'workspace' | 'flat'
onPick: (mode: 'workspace' | 'flat') => void
orderBy: SessionOrderBy
onGroupPick: (mode: 'workspace' | 'flat') => void
onOrderPick: (mode: SessionOrderBy) => void
t: WorkspaceBrowserProps['t']
}) {
const [open, setOpen] = useState(false)
@@ -66,14 +70,19 @@ function GroupByMenu({ groupBy, onPick, t }: {
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
{ id: 'workspace', label: t('groupBy.workspace') },
{ id: 'flat', label: t('groupBy.flat') },
{ type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
{ id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' },
{ id: 'created', label: t('orderBy.created') },
{ id: 'updated', label: t('orderBy.updated') },
]}
selectedId={groupBy}
selectedIds={[groupBy, orderBy]}
onSelect={(id) => {
/* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */
if (id === 'workspace' || id === 'flat') onPick(id)
if (id === 'workspace' || id === 'flat') onGroupPick(id)
else if (id === 'manual' || id === 'created' || id === 'updated') onOrderPick(id)
setOpen(false)
}}
align="end"
dense
// Portal: the section header clips overflow, so an in-place list would
// be cut off at the header's bounds.
portal
@@ -116,16 +125,19 @@ type SessionTreeProps = Pick<
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
/** Visual order; only manual mode exposes durable Workspace dragging. */
orderBy: SessionOrderBy
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, orderBy, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessionGroups, setExpandedSessionGroups] = 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
@@ -137,8 +149,8 @@ function SessionTree({
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }),
[list, workspaces, archivedSessionIds, expandedProjects],
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy),
[list, workspaces, archivedSessionIds, expandedProjects, orderBy],
)
const now = Date.now()
@@ -173,11 +185,14 @@ function SessionTree({
},
}}
/>
{group.sessions.map((node, index) => {
{(expandedSessionGroups.includes(group.key)
? group.sessions
: group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)
).map((node, index) => {
// 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
const draggable = group.workspaceId !== undefined && orderBy === 'manual'
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
start: () => {
@@ -223,6 +238,18 @@ function SessionTree({
/>
)
})}
{group.sessions.length > COLLAPSED_SESSION_LIMIT && (
<button
type="button"
className={css.sessionOverflowButton}
aria-expanded={expandedSessionGroups.includes(group.key)}
onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }}
>
{expandedSessionGroups.includes(group.key)
? t('sessions.collapse')
: t('sessions.expand', { n: group.sessions.length - COLLAPSED_SESSION_LIMIT })}
</button>
)}
</div>
))}
</div>
@@ -232,11 +259,14 @@ function SessionTree({
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, orderBy, t }: Pick<
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 'orderBy' | 't'
>) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds])
const rows = useMemo(
() => deriveFlat(list, archivedSessionIds, orderBy),
[list, archivedSessionIds, orderBy],
)
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
@@ -367,6 +397,12 @@ export function WorkspaceBrowser({
// flow reads): a composition without a picking affordance can add nothing.
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
const groupBy = useStore(s => s.groupBy)
// A live HMR handoff can retain the pre-ordering store instance until the
// slot is remounted; manual is the established Workspace order.
const orderBy = useStore(s => s.orderBy ?? 'manual')
// A flat list has no single Workspace account to drag. Keep the stored
// grouped preference intact while presenting the flat list by recency.
const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
// 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('')
@@ -548,7 +584,15 @@ export function WorkspaceBrowser({
{groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
</span>
)}
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} t={t} />}
{wide && (
<ViewOptionsMenu
groupBy={groupBy}
orderBy={effectiveOrderBy}
onGroupPick={(mode) => { actions.setGroupBy(mode) }}
onOrderPick={(mode) => { actions.setOrderBy(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. */}
@@ -644,7 +688,7 @@ export function WorkspaceBrowser({
<FlatList
useSessions={useSessions} open={open} forkSession={forkSession}
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
archivedSessionIds={archivedSessionIds} t={t}
archivedSessionIds={archivedSessionIds} orderBy={effectiveOrderBy} t={t}
/>
)
: (
@@ -658,6 +702,7 @@ export function WorkspaceBrowser({
startSession={startSession}
open={open}
insertSessionBefore={insertSessionBefore}
orderBy={orderBy}
t={t}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })

View File

@@ -13,6 +13,12 @@ export const zh = {
'groupBy.label': '分组方式',
'groupBy.workspace': '按工作区',
'groupBy.flat': '单列表',
'orderBy.label': '排序方式',
'orderBy.manual': '手动排序',
'orderBy.created': '创建时间',
'orderBy.updated': '最近更新',
'sessions.expand': '展开其余 {n} 个会话',
'sessions.collapse': '收起',
'empty.none': '暂无会话',
'empty.noMatches': '无匹配结果',
'workspace.add': '添加工作区',
@@ -76,6 +82,12 @@ export const en = {
'groupBy.label': 'Group by',
'groupBy.workspace': 'WorkSpace',
'groupBy.flat': 'In one list',
'orderBy.label': 'Order by',
'orderBy.manual': 'Manual',
'orderBy.created': 'Date created',
'orderBy.updated': 'Last updated',
'sessions.expand': 'Show {n} more sessions',
'sessions.collapse': 'Show less',
'empty.none': 'No sessions yet',
'empty.noMatches': 'No matches',
'workspace.add': 'Add workspace',

View File

@@ -82,14 +82,10 @@
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. */
/* Compact one-line Workspace row after removing the session-count subtitle. */
.projectRow {
height: 54px;
align-items: flex-start;
padding-top: 6px;
padding-bottom: 6px;
height: 36px;
align-items: center;
box-sizing: border-box;
}

View File

@@ -67,7 +67,7 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: {
}
/**
* Project (workspace) header row: 54px, folder + title + session count;
* Project (workspace) header row: folder + title;
* hover reveals the chevron and create button, and dwelling on a real
* Workspace shows its hover card (the ungrouped bucket has none).
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
@@ -89,7 +89,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
// 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 = 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 /> },
@@ -110,7 +109,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
</span>
<span className={css.projectText}>
<span className={css.title}>{label}</span>
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
{actions !== undefined && (

View File

@@ -9,9 +9,11 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run
/** Session-list grouping mode: workspace sections or one flat recency list. */
export type WorkspaceGroupBy = 'workspace' | 'flat'
/** Session order: durable Workspace order or a derived timestamp order. */
export type WorkspaceOrderBy = 'manual' | 'created' | 'updated'
/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */
type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
/** Workspace browser viewing state; transient expansion facts stay component-local. */
type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy }
/**
* Annotation twin of the actions literal below (the export needs a declared
@@ -19,6 +21,7 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
*/
type WorkspaceViewActions = {
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
}
/**
@@ -27,10 +30,12 @@ type WorkspaceViewActions = {
*/
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
return defineStore({
init: (): WorkspaceViewState => ({ groupBy: 'workspace' }),
persist: 'dsh.workspace.view',
init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual' }),
// The added order field changes the whole-value persistence format.
persist: 'dsh.workspace.view.v2',
actions: {
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
},
})
}

View File

@@ -29,9 +29,13 @@ export interface SessionNode {
runningSubagentCount: number
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
createdAt: number
updatedAt: number
}
/** Session order selected by the Workspace browser. */
export type SessionOrderBy = 'manual' | 'created' | 'updated'
/** One workspace group section: header row facts + visible top-level session rows. */
export interface GroupNode {
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
@@ -104,6 +108,16 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
return a.id < b.id ? -1 : 1
}
/** Newest-created first, id as the deterministic tiebreak. */
function byCreation(a: SessionSummary, b: SessionSummary): number {
if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt
return a.id < b.id ? -1 : 1
}
function sortSessions(sessions: SessionSummary[], orderBy: Exclude<SessionOrderBy, 'manual'>): void {
sessions.sort(orderBy === 'created' ? byCreation : byRecency)
}
/**
* Ordinary sessions are visible; among blank sessions, only the current one
* is visible. Subagent children use their parent header catalog; archived
@@ -133,12 +147,10 @@ function buildGroup(
createdAt: number | undefined,
label: string,
members: readonly SessionSummary[],
order: 'account' | 'recency',
orderBy: SessionOrderBy,
): Group {
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)
if (orderBy !== 'manual') sortSessions(sessions, orderBy)
return { key, workspaceId, cwd, createdAt, label, sessions }
}
@@ -151,6 +163,7 @@ function groupByWorkspace(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archived: ReadonlySet<SessionId>,
orderBy: SessionOrderBy,
): Group[] {
const groups: Group[] = []
const accounted = new Set<SessionId>()
@@ -165,7 +178,7 @@ function groupByWorkspace(
}
groups.push(buildGroup(
workspace.workspaceId, workspace.workspaceId, workspace.path,
Date.parse(workspace.createdAt), workspace.title, members, 'account',
Date.parse(workspace.createdAt), workspace.title, members, orderBy,
))
}
const stray = list.ids
@@ -173,7 +186,10 @@ function groupByWorkspace(
.filter((s): s is SessionSummary =>
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'))
groups.push(buildGroup(
UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray,
orderBy === 'manual' ? 'updated' : orderBy,
))
}
return groups
}
@@ -189,6 +205,7 @@ function sessionNode(
running: s.running,
runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
completed: s.completed === true,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
}
@@ -213,6 +230,7 @@ export function deriveGroups(
workspaces: readonly WorkspaceView[],
archivedSessionIds: readonly SessionId[],
view: TreeView,
orderBy: SessionOrderBy = 'manual',
): GroupNode[] {
const archived = new Set(archivedSessionIds)
const expandedProjects = new Set(view.expandedProjects)
@@ -222,7 +240,7 @@ export function deriveGroups(
: (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, archived)) {
for (const g of groupByWorkspace(list, workspaces, archived, orderBy)) {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
@@ -248,7 +266,11 @@ export function deriveGroups(
* @param archivedSessionIds - registry-global archive set.
* @returns flat rows in render order.
*/
export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
export function deriveFlat(
list: SessionListState,
archivedSessionIds: readonly SessionId[],
orderBy: SessionOrderBy = 'updated',
): SessionNode[] {
const archived = new Set(archivedSessionIds)
const descendants = indexSubagentDescendants(list.byId)
const rows: SessionSummary[] = []
@@ -257,7 +279,7 @@ export function deriveFlat(list: SessionListState, archivedSessionIds: readonly
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
rows.push(s)
}
rows.sort(byRecency)
sortSessions(rows, orderBy === 'manual' ? 'updated' : orderBy)
return rows.map(session => sessionNode(session, descendants))
}