ソースを参照

feat(client): improve workspace session browsing

_Kerman 2 ヶ月 前
親
コミット
8e0cb2bdba

+ 10 - 7
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<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
   const modelSelections = new Map<SessionId, ModelSelection>(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 },
         })

+ 1 - 0
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
 }
 

+ 2 - 0
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,

+ 14 - 6
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

+ 4 - 0
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<Partial<SessionProjectionMap>>
@@ -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) {

+ 9 - 0
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;

+ 9 - 4
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 (
       <div
         key={entry.id}
@@ -214,7 +219,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
         <button
           type="button"
           role="menuitem"
-          className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
+          className={clsx(css.item, selected && css.selected, entry.danger === true && css.danger)}
           disabled={entry.disabled}
           aria-haspopup={hasSub ? 'menu' : undefined}
           aria-expanded={hasSub ? subOpen : undefined}
@@ -230,7 +235,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
           {entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
           <span className={css.itemLabel}>{entry.label}</span>
           {/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
-          {entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
+          {selected && <IconCheckOutline16 className={css.check} />}
         </button>
         {subOpen && entry.submenu !== undefined && (
           <div className={clsx(css.submenu, compact && css.compactList)} role="menu">
@@ -260,7 +265,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
   const list = open && (
     <div
       ref={listRef}
-      className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
+      className={clsx(css.list, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
       style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
       role="menu"
       // React portals bubble synthetic events through the REACT tree: without

+ 22 - 4
packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css

@@ -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);

+ 63 - 18
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<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 })

+ 12 - 0
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',

+ 3 - 7
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;
 }
 

+ 1 - 3
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: <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 && (

+ 9 - 4
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<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 },
     },
   })
 }

+ 31 - 9
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<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))
 }
 

+ 3 - 0
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),

+ 1 - 0
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(),

+ 1 - 0
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'

+ 1 - 0
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(),

+ 2 - 0
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