diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 08837db9c5..07e437ecf1 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1387,10 +1387,11 @@ interface FixtureWorld { /** Build the fixture's legacy API and Remote RPC faces over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. + const fixtureSessionsNow = Date.now() const sessions: SessionSummary[] = options.empty ? [] : [ - { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, - { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, - { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, + { sessionId: sid('fx-alpha'), createdAt: fixtureSessionsNow - 180_000, updatedAt: fixtureSessionsNow, running: true, blank: false, cwd: '/tmp/fixture' }, + { sessionId: sid('fx-beta'), createdAt: fixtureSessionsNow - 120_000, updatedAt: fixtureSessionsNow - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, + { sessionId: sid('fx-gamma'), createdAt: fixtureSessionsNow - 60_000, updatedAt: fixtureSessionsNow - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) const modelSelections = new Map(sessions.map(session => [ @@ -2046,15 +2047,16 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return ok(request, { sessionId: requestedId }) } } + const createdAt = Date.now() const created: SessionSummary = { - sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, + sessionId: requestedId ?? sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: true, cwd, } sessions.push(created) modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) attachedSessions += 1 const emitSession = (): void => { // Mirrors the host: the frame fires at creation, so blank is constantly true. - emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd }) + emitHost({ type: 'host/session-added', sessionId: created.sessionId, createdAt, blank: true, cwd }) } if (workspace !== undefined && options.failWorkspaceAttach) { emitSession() @@ -2121,15 +2123,16 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } let cut = boundary.seq + 1 while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ + const createdAt = Date.now() const child: SessionSummary = { - sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, + sessionId: sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: false, parentSessionId: sessionId, ...source.cwd === undefined ? {} : { cwd: source.cwd }, } logs.set(child.sessionId, log.slice(0, cut)) sessions.push(child) emitHost({ - type: 'host/session-added', sessionId: child.sessionId, blank: false, + type: 'host/session-added', sessionId: child.sessionId, createdAt, blank: false, parentSessionId: sessionId, ...source.cwd === undefined ? {} : { cwd: source.cwd }, }) diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts index 466e26fe12..5f694921b1 100644 --- a/packages/client/runtime/src/client/contract/sessions-port.ts +++ b/packages/client/runtime/src/client/contract/sessions-port.ts @@ -16,6 +16,7 @@ export interface SessionsPortSummary { /** Empty-log bit (blank sessions are reused by New Session instead of minting another). */ blank: boolean cwd?: string + createdAt: number updatedAt: number } diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index cf8fa0834d..5e54cb59fd 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -17,6 +17,7 @@ export interface TitledSessionSummary extends SessionSummary { export interface SessionListEntry { sessionId: SessionId title?: string + createdAt: number updatedAt: number running: boolean /** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */ @@ -77,6 +78,7 @@ export function flattenLineage( const pendingInteraction = pendingInteractions?.get(s.sessionId) out.push({ ...s, + createdAt: s.createdAt ?? s.updatedAt, ...(pendingInteraction === undefined ? {} : { pendingInteraction }), completed: completed?.has(s.sessionId) ?? false, depth, diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 741b3d36b2..a6e7bf867c 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -540,8 +540,9 @@ export class SessionManager { : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } const { result } = await this.api.sessions.create(payload) if (result.ok) { + const createdAt = Date.now() this.recordMutation({ kind: 'upsert', summary: { - sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, + sessionId: result.value.sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), ...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}), } }) @@ -551,9 +552,11 @@ export class SessionManager { // so expose it immediately as Ungrouped while the caller keeps the // prompt buffer and decides whether to retry attachment. if (publishedSessionId !== undefined) { + const createdAt = Date.now() this.recordMutation({ kind: 'upsert', summary: { sessionId: publishedSessionId, - updatedAt: Date.now(), + createdAt, + updatedAt: createdAt, running: false, blank: true, } }) @@ -587,8 +590,9 @@ export class SessionManager { ? result.value.sessionId : workspaceAttachSessionId(result.error) if (childId !== undefined) { + const createdAt = Date.now() this.recordMutation({ kind: 'upsert', summary: { - sessionId: childId, updatedAt: Date.now(), running: false, blank: false, + sessionId: childId, createdAt, updatedAt: createdAt, running: false, blank: false, parentSessionId: opts.sessionId, ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), } }) @@ -615,8 +619,9 @@ export class SessionManager { * @param agentPreset - the preset id the host confirmed. */ noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + const createdAt = Date.now() this.recordMutation({ kind: 'upsert', summary: { - sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset, + sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, agentPreset, } }) } @@ -783,8 +788,10 @@ export class SessionManager { const frame = envelope.payload switch (frame.type) { case 'host/session-added': { + const createdAt = frame.createdAt ?? Date.now() this.mergeSummary({ - sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank, + sessionId: frame.sessionId, createdAt, updatedAt: createdAt, + running: false, blank: frame.blank, ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), ...(frame.origin !== undefined ? { origin: frame.origin } : {}), ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), @@ -1037,7 +1044,8 @@ export class SessionManager { const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( - prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running + prev !== undefined && prev.createdAt === entry.createdAt + && prev.updatedAt === entry.updatedAt && prev.running === entry.running && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b7267402e..c8e5412696 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -66,6 +66,8 @@ export interface SessionSummary { * selected blank entry. */ blank: boolean + /** Durable session creation time. */ + createdAt: number updatedAt: number /** Current host-computed projection values retained by the object layer. */ projectionValues?: Readonly> @@ -667,6 +669,7 @@ export class SessionsService implements ISessions { running: entry.running, ...(entry.completed ? { completed: true } : {}), blank: entry.blank, + createdAt: entry.createdAt, updatedAt: entry.updatedAt, ...(entry.pendingInteraction === undefined ? {} @@ -700,6 +703,7 @@ export class SessionsService implements ISessions { origin: 'subagent', running: child.activity === 'running', blank: false, + createdAt: 0, updatedAt: 0, } } else if (summary.displayTitle !== displayTitle) { diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 20c1584c1a..7bc0c5aced 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -115,6 +115,15 @@ background: var(--dsw-alias-interactive-bg-hover); } +.denseList .item { + min-height: 34px; + padding-block: 5px; +} + +.denseList .label { + padding-block: 4px; +} + .list.compactList, .submenu.compactList { min-width: 164px; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ea7e51b478..46c30b8afb 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -62,6 +62,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. + * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). @@ -74,6 +75,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * both trigger and list for the pointer grace (default false keeps it open * until outside click/Escape/selection). The grace makes the 4px trigger->list * gap and a brief overshoot survivable; coming back cancels the close. + * @param props.dense - reduce vertical row spacing without changing the standard typography or card width. * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the @@ -85,18 +87,20 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * by a hairline; they stay visible while the items above scroll. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, getAnchorRect, footer, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] selectedId?: string | undefined + selectedIds?: readonly string[] | undefined onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' side?: 'bottom' | 'top' | 'right' portal?: boolean closeOnPointerLeave?: boolean + dense?: boolean compact?: boolean getAnchorRect?: () => DOMRect | null className?: string @@ -204,6 +208,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id + const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true return (
{entry.icon}} {entry.label} {/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */} - {entry.id === selectedId && } + {selected && } {subOpen && entry.submenu !== undefined && (
@@ -260,7 +265,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const list = open && (
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); diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index a049b3f3d1..4faa479e70 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -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([]) + const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag viewing state (never store-bound; order truth stays Host-side). const [drag, setDrag] = useState(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 && ( + + )}
))}
@@ -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 (
@@ -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')} )} - {wide && { actions.setGroupBy(mode) }} t={t} />} + {wide && ( + { 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({ ) : ( @@ -658,6 +702,7 @@ export function WorkspaceBrowser({ startSession={startSession} open={open} insertSessionBefore={insertSessionBefore} + orderBy={orderBy} t={t} onRenameRequest={(workspaceId, currentTitle) => { setRenameTarget({ workspaceId, currentTitle }) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 30fe6bfcc0..c816326157 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -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', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 612eb3e306..71be79d081 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -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; } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 71c0b05af5..5ed290f20d 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -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: }, @@ -110,7 +109,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { {label} - {count} {actions !== undefined && ( diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index ed89d80d9e..91abedccfa 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -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 { 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 }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 008ab687f7..5120e31931 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -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): 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, + orderBy: SessionOrderBy, ): Group[] { const groups: Group[] = [] const accounted = new Set() @@ -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)) } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c4e0a16756..b01ef9d636 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -466,6 +466,7 @@ function sessionListFields(header: SessionHeader, events: readonly SessionEvent[ function summarize(session: Session, running: boolean): SessionSummary { return { sessionId: session.id, + createdAt: session.header.createdAt, // Excludes end-seed: a resumed-but-untouched session // must not sort as freshly worked in. updatedAt: lastActivityTime(session.events) ?? session.header.createdAt, @@ -499,6 +500,7 @@ async function summarizeCold( } return { sessionId: meta.id, + createdAt: meta.createdAt, updatedAt, running: false, // Lazy persistence keeps never-appended sessions out of list(); reading @@ -3377,6 +3379,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/session-added', sessionId: session.id, + createdAt: session.header.createdAt, // Derived at frame time like summarize(); a just-created session // has run no turn yet, so this is constantly true in practice. blank: sessionBlank(session), diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 02516c13bb..baa4b6101a 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -71,6 +71,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, + createdAt: z.number().optional(), blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), origin: z.literal('subagent').optional(), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 73bb9d8bc2..7069efecf3 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -127,6 +127,7 @@ export type HostFrame = | { type: 'host/session-added' sessionId: SessionId + createdAt?: number blank: boolean parentSessionId?: SessionId origin?: 'subagent' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 5c4647769a..18d162a95c 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -51,6 +51,7 @@ export const sessionEventSchema = z.object({ /** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ export const sessionSummarySchema = z.object({ sessionId: sessionIdSchema, + createdAt: z.number().optional(), updatedAt: z.number(), running: z.boolean(), blank: z.boolean(), diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index d1f0317e8f..24272dcf07 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -147,6 +147,8 @@ export type QueueAction = /** Session list entry (v1 builds no index: list does readdir+stat). */ export interface SessionSummary { sessionId: SessionId + /** Session creation time from the durable session header when supplied by the Host. */ + createdAt?: number /** * Last activity. Attached: the last non-`session/end-seed` event, since a * pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend