Просмотр исходного кода

feat(client): refine workspace sidebar interactions

_Kerman 2 месяцев назад
Родитель
Сommit
3f315a0134

+ 5 - 5
packages/client/ui-settings/src/client/SettingsRoot.module.css

@@ -1,6 +1,6 @@
 /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar
 /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar
    foot trigger row + centered 1080x700 modal panel. The trigger uses the
    foot trigger row + centered 1080x700 modal panel. The trigger uses the
-   sidebar's 38px wide row / 36px rail circle rhythm; the
+   sidebar's 34px compact row / 36px rail circle rhythm; the
    panel is a two-column layout — 188px nav rail + content column with a
    panel is a two-column layout — 188px nav rail + content column with a
    54px header and the 24px-padded options area. */
    54px header and the 24px-padded options area. */
 
 
@@ -10,10 +10,10 @@
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   gap: 8px;
   gap: 8px;
-  width: calc(100% - 8px);
-  height: 38px;
-  margin: 4px 4px 4px;
-  padding: 8px 2px 8px 10px;
+  width: calc(100% + 8px);
+  height: 34px;
+  margin: 4px -4px 4px;
+  padding: 6px 2px 6px 10px;
   box-sizing: border-box;
   box-sizing: border-box;
   border: none;
   border: none;
   border-radius: 12px;
   border-radius: 12px;

+ 45 - 9
packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css

@@ -38,6 +38,19 @@
   background: var(--dsw-alias-interactive-bg-hover);
   background: var(--dsw-alias-interactive-bg-hover);
 }
 }
 
 
+.viewOptionLabel {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  width: 100%;
+}
+
+.viewOptionCheck {
+  flex: none;
+  color: var(--dsw-alias-label-primary);
+}
+
 /* Section header: title, an inline search control, and the two trailing
 /* Section header: title, an inline search control, and the two trailing
    actions. Expanding search collapses the action cluster and takes its room. */
    actions. Expanding search collapses the action cluster and takes its room. */
 .sectionHeader {
 .sectionHeader {
@@ -56,6 +69,7 @@
 }
 }
 
 
 .root:not(.rail) .sectionHeader {
 .root:not(.rail) .sectionHeader {
+  margin-top: 2px;
   margin-right: -4px;
   margin-right: -4px;
 }
 }
 
 
@@ -66,6 +80,23 @@
   overflow: hidden;
   overflow: hidden;
   white-space: nowrap;
   white-space: nowrap;
   line-height: 20px;
   line-height: 20px;
+  opacity: 1;
+  visibility: visible;
+  transition:
+    max-width 180ms var(--ds-ease-in-out),
+    margin-right 180ms var(--ds-ease-in-out),
+    opacity 120ms var(--ds-ease-in-out),
+    transform 180ms var(--ds-ease-in-out),
+    visibility 0s linear;
+}
+
+.sectionLabelHidden {
+  max-width: 0;
+  margin-right: -4px;
+  opacity: 0;
+  transform: translateX(-4px);
+  visibility: hidden;
+  transition-delay: 0s, 0s, 0s, 0s, 180ms;
 }
 }
 
 
 .searchSlot {
 .searchSlot {
@@ -84,7 +115,7 @@
 
 
 .searchSlotExpanded {
 .searchSlotExpanded {
   max-width: 100%;
   max-width: 100%;
-  padding-left: 4px;
+  padding-left: 0;
 }
 }
 
 
 .headerActions {
 .headerActions {
@@ -138,7 +169,9 @@
 }
 }
 
 
 .searchExpanded {
 .searchExpanded {
-  height: 26px;
+  width: calc(100% + 4px);
+  height: 34px;
+  margin-inline: -2px;
   padding: 0 4px 0 0;
   padding: 0 4px 0 0;
   border: 1px solid var(--dsw-alias-border-l2);
   border: 1px solid var(--dsw-alias-border-l2);
   border-radius: 10px;
   border-radius: 10px;
@@ -162,8 +195,8 @@
 }
 }
 
 
 .searchExpanded .searchButton {
 .searchExpanded .searchButton {
-  width: 26px;
-  height: 26px;
+  width: 28px;
+  height: 34px;
 }
 }
 
 
 .searchButton:hover {
 .searchButton:hover {
@@ -204,8 +237,8 @@
   display: inline-flex;
   display: inline-flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
-  width: 18px;
-  height: 18px;
+  width: 24px;
+  height: 24px;
   border: none;
   border: none;
   border-radius: 50%;
   border-radius: 50%;
   padding: 0;
   padding: 0;
@@ -214,6 +247,10 @@
   color: var(--dsw-alias-label-secondary);
   color: var(--dsw-alias-label-secondary);
 }
 }
 
 
+.clearButton:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
 /* Rail variant (own .rail class from the wide owner prop — the region never
 /* Rail variant (own .rail class from the wide owner prop — the region never
    reads the shell's class names): the two icon controls stack as 36x36
    reads the shell's class names): the two icon controls stack as 36x36
    circles matching the shell's rail rhythm. */
    circles matching the shell's rail rhythm. */
@@ -367,7 +404,7 @@
 
 
 .sessionOverflowButton {
 .sessionOverflowButton {
   width: 100%;
   width: 100%;
-  height: 30px;
+  height: 28px;
   border: none;
   border: none;
   border-radius: 8px;
   border-radius: 8px;
   padding: 0 12px 0 28px;
   padding: 0 12px 0 28px;
@@ -385,8 +422,6 @@
 .sessionOverflowButton:hover {
 .sessionOverflowButton:hover {
   background: transparent;
   background: transparent;
   color: var(--dsw-alias-label-secondary);
   color: var(--dsw-alias-label-secondary);
-  text-decoration: underline;
-  text-underline-offset: 2px;
 }
 }
 
 
 .empty {
 .empty {
@@ -438,6 +473,7 @@
   }
   }
 
 
   .search,
   .search,
+  .sectionLabel,
   .searchSlot,
   .searchSlot,
   .searchInput,
   .searchInput,
   .headerActions {
   .headerActions {

+ 161 - 33
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -12,11 +12,11 @@
 import { useEffect, useMemo, useRef, useState } from 'react'
 import { useEffect, useMemo, useRef, useState } from 'react'
 import clsx from 'clsx'
 import clsx from 'clsx'
 import {
 import {
-  Button, IconCloseFill14, IconPersonalizationOutline16,
+  Button, IconCheckOutline16, IconCloseFill14, IconPersonalizationOutline16,
   IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
   IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {
 import type {
-  SessionSearchResultItem, WorkspaceId, WorkspaceView,
+  SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-client-runtime/client'
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { WorkspaceBrowserProps } from './contract/slots.ts'
 import type { WorkspaceBrowserProps } from './contract/slots.ts'
 import type { SessionNode, SessionOrderBy } from './tree.ts'
 import type { SessionNode, SessionOrderBy } from './tree.ts'
@@ -37,6 +37,8 @@ const SEARCH_QUERY_MAX_CODE_UNITS = 500
 /** Session rows visible per Workspace before the local overflow control. */
 /** Session rows visible per Workspace before the local overflow control. */
 const COLLAPSED_SESSION_LIMIT = 5
 const COLLAPSED_SESSION_LIMIT = 5
 const EMPTY_WORKSPACE_EXPANSION: Readonly<Record<string, boolean>> = Object.freeze({})
 const EMPTY_WORKSPACE_EXPANSION: Readonly<Record<string, boolean>> = Object.freeze({})
+const EMPTY_RECENT_SESSION_ORDER: Readonly<Record<string, readonly string[]>> = Object.freeze({})
+const EMPTY_RECENT_SESSION_UPDATED_AT: Readonly<Record<string, Readonly<Record<string, number>>>> = Object.freeze({})
 
 
 /** Keep controlled input and RPC payload inside the session.search wire contract. */
 /** Keep controlled input and RPC payload inside the session.search wire contract. */
 function sanitizeSearchQuery(value: string): string {
 function sanitizeSearchQuery(value: string): string {
@@ -54,6 +56,33 @@ function toggled(list: readonly string[], key: string): string[] {
   return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
   return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
 }
 }
 
 
+/** Reconcile a stored view order with the Workspace's current session account. */
+function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] {
+  if (stored === undefined) return [...sessionIds]
+  const byId = new Map(sessionIds.map(id => [id as string, id]))
+  const ordered: SessionId[] = []
+  const included = new Set<string>()
+  for (const key of stored) {
+    const id = byId.get(key)
+    if (id === undefined || included.has(key)) continue
+    ordered.push(id)
+    included.add(key)
+  }
+  for (const id of sessionIds) {
+    if (included.has(id)) continue
+    ordered.push(id)
+  }
+  return ordered
+}
+
+/** Newest update first with stable Session identity as the tie-break. */
+function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number {
+  const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY
+  const bUpdatedAt = byId[b]?.updatedAt ?? Number.NEGATIVE_INFINITY
+  if (aUpdatedAt !== bUpdatedAt) return bUpdatedAt - aUpdatedAt
+  return a < b ? -1 : 1
+}
+
 /** Grouping and ordering menu; own open state so it resets with the wide chrome. */
 /** Grouping and ordering menu; own open state so it resets with the wide chrome. */
 function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
 function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
   groupBy: 'workspace' | 'flat'
   groupBy: 'workspace' | 'flat'
@@ -63,23 +92,27 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
   t: WorkspaceBrowserProps['t']
   t: WorkspaceBrowserProps['t']
 }) {
 }) {
   const [open, setOpen] = useState(false)
   const [open, setOpen] = useState(false)
+  const optionLabel = (label: string, selected: boolean) => (
+    <span className={css.viewOptionLabel}>
+      <span>{label}</span>
+      {selected && <IconCheckOutline16 className={css.viewOptionCheck} />}
+    </span>
+  )
   return (
   return (
     <Menu
     <Menu
       open={open}
       open={open}
       onClose={() => { setOpen(false) }}
       onClose={() => { setOpen(false) }}
       items={[
       items={[
         { type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
         { type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
-        { id: 'workspace', label: t('groupBy.workspace') },
-        { id: 'flat', label: t('groupBy.flat') },
+        { id: 'workspace', label: optionLabel(t('groupBy.workspace'), groupBy === 'workspace') },
+        { id: 'flat', label: optionLabel(t('groupBy.flat'), groupBy === 'flat') },
         { type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
         { 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') },
+        { id: 'manual', label: optionLabel(t('orderBy.manual'), orderBy === 'manual'), disabled: groupBy !== 'workspace' },
+        { id: 'updated', label: optionLabel(t('orderBy.updated'), orderBy === 'updated') },
       ]}
       ]}
-      selectedIds={[groupBy, orderBy]}
       onSelect={(id) => {
       onSelect={(id) => {
         if (id === 'workspace' || id === 'flat') onGroupPick(id)
         if (id === 'workspace' || id === 'flat') onGroupPick(id)
-        else if (id === 'manual' || id === 'created' || id === 'updated') onOrderPick(id)
+        else if (id === 'manual' || id === 'updated') onOrderPick(id)
         setOpen(false)
         setOpen(false)
       }}
       }}
       align="end"
       align="end"
@@ -133,6 +166,14 @@ type SessionTreeProps = Pick<
   workspaceExpansion: Readonly<Record<string, boolean>>
   workspaceExpansion: Readonly<Record<string, boolean>>
   /** Persist one Workspace group's zero-or-five-session state. */
   /** Persist one Workspace group's zero-or-five-session state. */
   setWorkspaceExpanded: (key: string, expanded: boolean) => void
   setWorkspaceExpanded: (key: string, expanded: boolean) => void
+  /** Editable orders used by recent-update mode. */
+  recentSessionOrder: Readonly<Record<string, readonly string[]>>
+  /** Last update timestamps observed by recent-update mode. */
+  recentSessionUpdatedAt: Readonly<Record<string, Readonly<Record<string, number>>>>
+  /** Replace one recent-mode order and its observed timestamps. */
+  syncRecentSessions: (workspaceKey: string, order: string[], updatedAt: Record<string, number>) => void
+  /** Apply a manual drag inside one recent-mode order. */
+  setRecentSessionOrder: (workspaceKey: string, order: string[]) => void
   /** Registry-global archive set (hidden rows). */
   /** Registry-global archive set (hidden rows). */
   archivedSessionIds: readonly SessionNode['id'][]
   archivedSessionIds: readonly SessionNode['id'][]
   /** Open the browser-owned rename dialog for a real Workspace group. */
   /** Open the browser-owned rename dialog for a real Workspace group. */
@@ -143,7 +184,7 @@ type SessionTreeProps = Pick<
   onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
   onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
   /** Archive a session (row menu action; the row disappears on the state echo). */
   /** Archive a session (row menu action; the row disappears on the state echo). */
   onSessionArchive: (sessionId: SessionNode['id']) => void
   onSessionArchive: (sessionId: SessionNode['id']) => void
-  /** Session visual order; only manual mode exposes durable Session dragging. */
+  /** Session visual order; manual mode drags durable order, updated mode drags its view order. */
   orderBy: SessionOrderBy
   orderBy: SessionOrderBy
 }
 }
 
 
@@ -152,14 +193,34 @@ function SessionTree({
   useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
   useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
   insertWorkspaceBefore, insertSessionBefore, orderBy,
   insertWorkspaceBefore, insertSessionBefore, orderBy,
-  workspaceExpansion, setWorkspaceExpanded, t,
+  workspaceExpansion, setWorkspaceExpanded,
+  recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t,
 }: SessionTreeProps) {
 }: SessionTreeProps) {
   const list = useSessions(s => s)
   const list = useSessions(s => s)
   const current = list.current
   const current = list.current
   const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
   const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
-  // Transient drag viewing state (never store-bound; order truth stays Host-side).
+  // Transient drag marker state; the selected mode owns the resulting order.
   const [drag, setDrag] = useState<DragState | null>(null)
   const [drag, setDrag] = useState<DragState | null>(null)
+  const sessionDropCommitted = useRef(false)
   const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
   const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
+  const sessionDragging = drag !== null
+  useEffect(() => {
+    if (!sessionDragging) return
+    // Row hover still owns the insertion marker. Accept the native drag at
+    // document level so releasing outside the list is not rendered as a
+    // rejected drop before dragend commits that last marker.
+    const acceptDrag = (event: DragEvent): void => {
+      event.preventDefault()
+      if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move'
+    }
+    const acceptDrop = (event: DragEvent): void => { event.preventDefault() }
+    document.addEventListener('dragover', acceptDrag)
+    document.addEventListener('drop', acceptDrop)
+    return () => {
+      document.removeEventListener('dragover', acceptDrag)
+      document.removeEventListener('drop', acceptDrop)
+    }
+  }, [sessionDragging])
   const currentGroup = current === undefined
   const currentGroup = current === undefined
     ? undefined
     ? undefined
     : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
     : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
@@ -172,11 +233,79 @@ function SessionTree({
     () => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
     () => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
     [workspaceExpansion],
     [workspaceExpansion],
   )
   )
+  useEffect(() => {
+    if (orderBy !== 'updated' || list.phase !== 'ready') return
+    for (const workspace of workspaces) {
+      const key = workspace.workspaceId as string
+      const sessionIds = workspace.sessionIds.filter(id => list.byId[id] !== undefined)
+      const previousOrder = recentSessionOrder[key]
+      const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {}
+      let nextOrder = reconciledSessionOrder(sessionIds, previousOrder)
+      if (previousOrder === undefined) {
+        nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId))
+      } else {
+        const promoted = sessionIds
+          .filter((id) => previousUpdatedAt[id] === undefined || list.byId[id]!.updatedAt > previousUpdatedAt[id]!)
+          .sort((a, b) => compareSessionRecency(a, b, list.byId))
+        if (promoted.length > 0) {
+          const promotedIds = new Set(promoted)
+          nextOrder = [...promoted, ...nextOrder.filter(id => !promotedIds.has(id))]
+        }
+      }
+      const nextUpdatedAt: Record<string, number> = {}
+      for (const id of sessionIds) nextUpdatedAt[id] = list.byId[id]!.updatedAt
+      const orderChanged = previousOrder === undefined
+        || nextOrder.length !== previousOrder.length
+        || nextOrder.some((id, index) => id !== previousOrder[index])
+      const timestampsChanged = Object.keys(nextUpdatedAt).length !== Object.keys(previousUpdatedAt).length
+        || Object.entries(nextUpdatedAt).some(([id, updatedAt]) => previousUpdatedAt[id] !== updatedAt)
+      if (orderChanged || timestampsChanged) {
+        syncRecentSessions(key, nextOrder.map(id => id as string), nextUpdatedAt)
+      }
+    }
+  }, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, workspaces])
+  const orderedWorkspaces = useMemo(() => {
+    if (orderBy !== 'updated') return workspaces
+    return workspaces.map((workspace) => {
+      const stored = recentSessionOrder[workspace.workspaceId as string]
+      const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored)
+      if (stored === undefined) sessionIds.sort((a, b) => compareSessionRecency(a, b, list.byId))
+      return { ...workspace, sessionIds }
+    })
+  }, [list.byId, orderBy, recentSessionOrder, workspaces])
   const groups = useMemo(
   const groups = useMemo(
-    () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy),
-    [list, workspaces, archivedSessionIds, expandedProjects, orderBy],
+    () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }, 'manual'),
+    [list, orderedWorkspaces, archivedSessionIds, expandedProjects],
   )
   )
   const now = Date.now()
   const now = Date.now()
+  const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
+    if (sessionDropCommitted.current) return
+    sessionDropCommitted.current = true
+    setDrag(null)
+    const group = groups.find(candidate => candidate.workspaceId === activeDrag.workspaceId)
+    if (group === undefined) return
+    const targetIndex = group.sessions.findIndex(session => session.id === over.id)
+    if (targetIndex === -1) return
+    const anchor = over.half === 'before' ? over.id : group.sessions[targetIndex + 1]?.id
+    if (anchor === activeDrag.sessionId) return
+    const sourceIndex = group.sessions.findIndex(session => session.id === activeDrag.sessionId)
+    const anchorIndex = anchor === undefined
+      ? group.sessions.length
+      : group.sessions.findIndex(session => session.id === anchor)
+    if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
+    if (orderBy === 'updated') {
+      const account = orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceId)
+      if (account === undefined) return
+      const nextOrder = account.sessionIds.filter(id => id !== activeDrag.sessionId)
+      const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
+      nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
+      setRecentSessionOrder(activeDrag.workspaceId as string, nextOrder.map(id => id as string))
+      return
+    }
+    insertSessionBefore(activeDrag.workspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
+      console.warn('session reorder rejected:', reason)
+    })
+  }
 
 
   return (
   return (
     <div className={clsx(css.treeBody, css.wide)}>
     <div className={clsx(css.treeBody, css.wide)}>
@@ -271,14 +400,15 @@ function SessionTree({
             {(expandedSessionGroups.includes(group.key)
             {(expandedSessionGroups.includes(group.key)
               ? group.sessions
               ? group.sessions
               : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)
               : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)
-            ).map((node, index) => {
+            ).map((node) => {
               // Draggable: real-workspace session rows. The drag
               // Draggable: real-workspace session rows. The drag
               // never leaves its group — rows of other groups show no markers
               // never leaves its group — rows of other groups show no markers
               // and reject drops (visual movement confined to this section).
               // and reject drops (visual movement confined to this section).
-              const draggable = group.workspaceId !== undefined && orderBy === 'manual'
+              const draggable = group.workspaceId !== undefined
               const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
               const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
               const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
               const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
                 start: () => {
                 start: () => {
+                  sessionDropCommitted.current = false
                   setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
                   setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
                 },
                 },
                 active: sameGroupDrag,
                 active: sameGroupDrag,
@@ -290,21 +420,13 @@ function SessionTree({
                 drop: (half: 'before' | 'after') => {
                 drop: (half: 'before' | 'after') => {
                   /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
                   /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
                   if (drag === null) return
                   if (drag === null) return
-                  const sessions = group.sessions
-                  // Anchor = the row the insert line points at ('after' means
-                  // the next root; end-of-list omits the anchor → append).
-                  const anchor = half === 'before' ? node.id : sessions[index + 1]?.id
-                  setDrag(null)
-                  if (anchor === drag.sessionId) return
-                  // No-op when the drop lands back on the source position.
-                  const sourceIndex = sessions.findIndex(r => r.id === drag.sessionId)
-                  const anchorIndex = anchor === undefined ? sessions.length : sessions.findIndex(r => r.id === anchor)
-                  if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
-                  insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => {
-                    console.warn('session reorder rejected:', reason)
-                  })
+                  commitSessionDrag(drag, { id: node.id, half })
+                },
+                end: () => {
+                  if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over)
+                  else setDrag(null)
+                  sessionDropCommitted.current = false
                 },
                 },
-                end: () => { setDrag(null) },
               }
               }
               return (
               return (
                 <SessionNodeItem
                 <SessionNodeItem
@@ -490,6 +612,8 @@ export function WorkspaceBrowser({
   const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
   const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
   // HMR can retain the preceding view-store instance until the slot remounts.
   // HMR can retain the preceding view-store instance until the slot remounts.
   const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION)
   const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION)
+  const recentSessionOrder = useStore(s => s.recentSessionOrder ?? EMPTY_RECENT_SESSION_ORDER)
+  const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt ?? EMPTY_RECENT_SESSION_UPDATED_AT)
   // The query outlives the tree and the input (both wide-only) so collapsing
   // The query outlives the tree and the input (both wide-only) so collapsing
   // does not silently drop an in-progress filter.
   // does not silently drop an in-progress filter.
   const [query, setQuery] = useState('')
   const [query, setQuery] = useState('')
@@ -532,12 +656,12 @@ export function WorkspaceBrowser({
     const onClick = (event: MouseEvent): void => {
     const onClick = (event: MouseEvent): void => {
       if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
       if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
       searchInput.current?.blur()
       searchInput.current?.blur()
-      setQuery('')
+      if (query !== '') return
       setSearchExpanded(false)
       setSearchExpanded(false)
     }
     }
     document.addEventListener('click', onClick)
     document.addEventListener('click', onClick)
     return () => { document.removeEventListener('click', onClick) }
     return () => { document.removeEventListener('click', onClick) }
-  }, [wide, searchExpanded])
+  }, [query, wide, searchExpanded])
 
 
   useEffect(() => {
   useEffect(() => {
     if (normalizedQuery === '') {
     if (normalizedQuery === '') {
@@ -686,7 +810,7 @@ export function WorkspaceBrowser({
     <div className={clsx(css.root, !wide && css.rail)}>
     <div className={clsx(css.root, !wide && css.rail)}>
       <div className={css.sectionHeader}>
       <div className={css.sectionHeader}>
         {wide && (
         {wide && (
-          <span className={clsx(css.sectionLabel, css.wide)}>
+          <span className={clsx(css.sectionLabel, css.wide, searchExpanded && css.sectionLabelHidden)}>
             {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
             {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
           </span>
           </span>
         )}
         )}
@@ -846,6 +970,10 @@ export function WorkspaceBrowser({
                 workspaces={workspaces}
                 workspaces={workspaces}
                 workspaceExpansion={workspaceExpansion}
                 workspaceExpansion={workspaceExpansion}
                 setWorkspaceExpanded={actions.setWorkspaceExpanded}
                 setWorkspaceExpanded={actions.setWorkspaceExpanded}
+                recentSessionOrder={recentSessionOrder}
+                recentSessionUpdatedAt={recentSessionUpdatedAt}
+                syncRecentSessions={actions.syncRecentSessions}
+                setRecentSessionOrder={actions.setRecentSessionOrder}
                 archivedSessionIds={archivedSessionIds}
                 archivedSessionIds={archivedSessionIds}
                 startSession={startSession}
                 startSession={startSession}
                 open={open}
                 open={open}

+ 0 - 2
packages/client/ui-workspace/src/client/locales.ts

@@ -15,7 +15,6 @@ export const zh = {
   'groupBy.flat': '单列表',
   'groupBy.flat': '单列表',
   'orderBy.label': '排序方式',
   'orderBy.label': '排序方式',
   'orderBy.manual': '手动排序',
   'orderBy.manual': '手动排序',
-  'orderBy.created': '创建时间',
   'orderBy.updated': '最近更新',
   'orderBy.updated': '最近更新',
   'sessions.expand': '展开其余 {n} 个会话',
   'sessions.expand': '展开其余 {n} 个会话',
   'sessions.collapse': '收起',
   'sessions.collapse': '收起',
@@ -84,7 +83,6 @@ export const en = {
   'groupBy.flat': 'In one list',
   'groupBy.flat': 'In one list',
   'orderBy.label': 'Order by',
   'orderBy.label': 'Order by',
   'orderBy.manual': 'Manual',
   'orderBy.manual': 'Manual',
-  'orderBy.created': 'Date created',
   'orderBy.updated': 'Last updated',
   'orderBy.updated': 'Last updated',
   'sessions.expand': 'Show {n} more sessions',
   'sessions.expand': 'Show {n} more sessions',
   'sessions.collapse': 'Show less',
   'sessions.collapse': 'Show less',

+ 18 - 7
packages/client/ui-workspace/src/client/rows/Rows.module.css

@@ -1,5 +1,5 @@
-/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px
-   single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps
+/* Tree rows: project 34px, session 32px, radius 8, indent step 22px
+   (16px slot + 6px gap). Hover swaps
    are pure CSS: project folder -> chevron + action buttons; session time ->
    are pure CSS: project folder -> chevron + action buttons; session time ->
    ellipsis button. */
    ellipsis button. */
 
 
@@ -29,11 +29,11 @@
   flex-direction: column;
   flex-direction: column;
   align-items: stretch;
   align-items: stretch;
   width: 100%;
   width: 100%;
-  min-height: 62px;
+  min-height: 48px;
   box-sizing: border-box;
   box-sizing: border-box;
   border: none;
   border: none;
   border-radius: 8px;
   border-radius: 8px;
-  padding: 7px 8px;
+  padding: 4px 8px;
   background: transparent;
   background: transparent;
   cursor: pointer;
   cursor: pointer;
   text-align: left;
   text-align: left;
@@ -64,9 +64,16 @@
   line-height: 20px;
   line-height: 20px;
 }
 }
 
 
+.searchResultMeta {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+  margin-left: 20px;
+}
+
 .searchResultWorkspace,
 .searchResultWorkspace,
 .searchResultSnippet {
 .searchResultSnippet {
-  margin-left: 20px;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   white-space: nowrap;
   white-space: nowrap;
@@ -75,16 +82,20 @@
 }
 }
 
 
 .searchResultWorkspace {
 .searchResultWorkspace {
+  flex: none;
+  max-width: 40%;
   color: var(--dsw-alias-label-tertiary);
   color: var(--dsw-alias-label-tertiary);
 }
 }
 
 
 .searchResultSnippet {
 .searchResultSnippet {
+  flex: 1;
+  min-width: 0;
   color: var(--dsw-alias-label-secondary);
   color: var(--dsw-alias-label-secondary);
 }
 }
 
 
 /* Compact one-line Workspace row after removing the session-count subtitle. */
 /* Compact one-line Workspace row after removing the session-count subtitle. */
 .projectRow {
 .projectRow {
-  height: 36px;
+  height: 34px;
   align-items: center;
   align-items: center;
   box-sizing: border-box;
   box-sizing: border-box;
 }
 }
@@ -95,7 +106,7 @@
 
 
 /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
 /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
 .sessionRow {
 .sessionRow {
-  height: 34px;
+  height: 32px;
   gap: 0;
   gap: 0;
   /* Mount fade: session rows appear by unfolding a group (or the tree
   /* Mount fade: session rows appear by unfolding a group (or the tree
      mounting). Stable row keys keep already-visible rows from replaying it. */
      mounting). Stable row keys keep already-visible rows from replaying it. */

+ 6 - 4
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -317,10 +317,12 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
         </span>
         </span>
         <span className={css.searchResultTitle}>{result.title}</span>
         <span className={css.searchResultTitle}>{result.title}</span>
       </span>
       </span>
-      <span className={css.searchResultWorkspace}>{result.workspace}</span>
-      {result.snippet !== undefined && (
-        <span className={css.searchResultSnippet}>{result.snippet}</span>
-      )}
+      <span className={css.searchResultMeta}>
+        <span className={css.searchResultWorkspace}>{result.workspace}</span>
+        {result.snippet !== undefined && (
+          <span className={css.searchResultSnippet}>{result.snippet}</span>
+        )}
+      </span>
     </button>
     </button>
   )
   )
 }
 }

+ 28 - 4
packages/client/ui-workspace/src/client/stores.ts

@@ -9,8 +9,8 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run
 
 
 /** Session-list grouping mode: workspace sections or one flat recency list. */
 /** Session-list grouping mode: workspace sections or one flat recency list. */
 export type WorkspaceGroupBy = 'workspace' | 'flat'
 export type WorkspaceGroupBy = 'workspace' | 'flat'
-/** Session order: durable Workspace order or a derived timestamp order. */
-export type WorkspaceOrderBy = 'manual' | 'created' | 'updated'
+/** Session order: durable Workspace order or an activity-promoted editable order. */
+export type WorkspaceOrderBy = 'manual' | 'updated'
 
 
 /** Workspace browser viewing state persisted across surface remounts and reloads. */
 /** Workspace browser viewing state persisted across surface remounts and reloads. */
 type WorkspaceViewState = {
 type WorkspaceViewState = {
@@ -18,6 +18,10 @@ type WorkspaceViewState = {
   orderBy: WorkspaceOrderBy
   orderBy: WorkspaceOrderBy
   /** Explicit zero-or-five-session state keyed by Workspace group identity. */
   /** Explicit zero-or-five-session state keyed by Workspace group identity. */
   workspaceExpansion: Record<string, boolean>
   workspaceExpansion: Record<string, boolean>
+  /** Editable per-Workspace order used by recent-update mode. */
+  recentSessionOrder: Record<string, string[]>
+  /** Last observed update timestamps used to detect promotion events. */
+  recentSessionUpdatedAt: Record<string, Record<string, number>>
 }
 }
 
 
 /**
 /**
@@ -28,6 +32,13 @@ type WorkspaceViewActions = {
   setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
   setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
   setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
   setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
   setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
   setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
+  syncRecentSessions: (
+    draft: WorkspaceViewState,
+    workspaceKey: string,
+    order: string[],
+    updatedAt: Record<string, number>,
+  ) => void
+  setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void
 }
 }
 
 
 /**
 /**
@@ -36,12 +47,25 @@ type WorkspaceViewActions = {
  */
  */
 export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
 export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
   return defineStore({
   return defineStore({
-    init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual', workspaceExpansion: {} }),
-    persist: 'dsh.workspace.view.v3',
+    init: (): WorkspaceViewState => ({
+      groupBy: 'workspace',
+      orderBy: 'manual',
+      workspaceExpansion: {},
+      recentSessionOrder: {},
+      recentSessionUpdatedAt: {},
+    }),
+    persist: 'dsh.workspace.view.v4',
     actions: {
     actions: {
       setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
       setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
       setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
       setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
       setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded },
       setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded },
+      syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record<string, number>) => {
+        d.recentSessionOrder[workspaceKey] = order
+        d.recentSessionUpdatedAt[workspaceKey] = updatedAt
+      },
+      setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => {
+        d.recentSessionOrder[workspaceKey] = order
+      },
     },
     },
   })
   })
 }
 }

+ 4 - 10
packages/client/ui-workspace/src/client/tree.ts

@@ -34,7 +34,7 @@ export interface SessionNode {
 }
 }
 
 
 /** Session order selected by the Workspace browser. */
 /** Session order selected by the Workspace browser. */
-export type SessionOrderBy = 'manual' | 'created' | 'updated'
+export type SessionOrderBy = 'manual' | 'updated'
 
 
 /** One workspace group section: header row facts + visible top-level session rows. */
 /** One workspace group section: header row facts + visible top-level session rows. */
 export interface GroupNode {
 export interface GroupNode {
@@ -108,14 +108,8 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
   return a.id < b.id ? -1 : 1
   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)
+function sortSessions(sessions: SessionSummary[]): void {
+  sessions.sort(byRecency)
 }
 }
 
 
 /**
 /**
@@ -150,7 +144,7 @@ function buildGroup(
   orderBy: SessionOrderBy,
   orderBy: SessionOrderBy,
 ): Group {
 ): Group {
   const sessions = [...members]
   const sessions = [...members]
-  if (orderBy !== 'manual') sortSessions(sessions, orderBy)
+  if (orderBy !== 'manual') sortSessions(sessions)
   return { key, workspaceId, cwd, createdAt, label, sessions }
   return { key, workspaceId, cwd, createdAt, label, sessions }
 }
 }