Explorar o código

feat(ui-workspace): archive session from the row menu

The visual-only Delete session placeholder becomes a wired Archive
session action: no confirmation dialog (non-destructive), failures stay
console diagnostics. tree.ts hides archived sessions in every
derivation (workspace groups, Ungrouped, search, flat list) through the
sessionVisible predicate. The workspace-management e2e pins the archive
round trip across reload.
imccyu hai 2 meses
pai
achega
e235be2bac

+ 48 - 4
apps/web/tests/workspace-management.e2e.ts

@@ -1,10 +1,13 @@
 // Web e2e scenarios: workspace management — the create-by-name dialog, the
 // rename round trip over the real wire (workspace.rename RPC + durable
 // registry), duplicate-name pre-check, the flat "In one list" view with its
-// persisted group-by preference, and the session hover card. Zero model
-// calls: workspace.create/rename are host RPCs with no model involvement,
-// and the one session row the flat/hover scenarios need comes from a seeded
-// fixture (the seeded-history seed reused verbatim — no new recording).
+// persisted group-by preference, the session hover card, and the session
+// archive round trip (row menu → workspace.archiveSession RPC → durable
+// global set → row hidden across reload). Zero model calls:
+// workspace.create/rename/archiveSession are host RPCs with no model
+// involvement, and the one session row the flat/hover/archive scenarios need
+// comes from a seeded fixture (the seeded-history seed reused verbatim — no
+// new recording).
 import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -413,6 +416,47 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
+    // The seeded session lives under Ungrouped (expanded by the hover-card
+    // test's gesture; converge again for order independence).
+    const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
+    const ungroupedSection = ungroupedRow.locator('..')
+    await expect.poll(async () => {
+      if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
+        await page.getByText('Ungrouped', { exact: true }).click()
+        await page.waitForTimeout(50)
+      }
+      return await ungroupedRow.getAttribute('aria-expanded')
+    }, { timeout: 5_000 }).toBe('true')
+    const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
+    const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
+    // Row menu: hover reveals the actions button; Archive session commits
+    // without a confirmation dialog (non-destructive: log + accounting stay).
+    await sessionRow.hover()
+    await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click()
+    await page.getByRole('menuitem', { name: 'Archive session' }).click()
+    // The row disappears on the archive-set echo; with no other visible
+    // stray, the whole Ungrouped bucket withdraws.
+    await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+    await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+    // Durable on the host: the registry-global set carries the id while the
+    // session log itself stays in persistence untouched.
+    expect([...scaffold.ctx.workspace.archivedSessionIds]).toEqual([SessionId(SEED_ID)])
+    expect((await scaffold.ctx.sessionPersistence.list()).map(header => header.id)).toContain(SessionId(SEED_ID))
+    // Reload: the hidden state is rebuilt from the workspace.list baseline.
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    // The archived row must not resurface (the Ungrouped bucket itself may
+    // reappear if selection restore lands on another stray — not this test's
+    // concern).
+    expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 90_000)
+
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     expect(tripwire.warnings).toEqual([])
     // The directory-browser aria golden is this spec's one owned artifact;

+ 2 - 2
packages/client/ui-workspace/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
-README.md: f71bfa09c795bd69e1f49c8f6dffffd5959dbe47
-README.zh.md: 80b53d85eb210b0e7a7ace1699d6bbfc9a836606
+README.md: cc73214a281c6950acf8846f0bae3214c8726934
+README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/client/ui-workspace/README.md


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
packages/client/ui-workspace/README.zh.md


+ 35 - 9
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -102,18 +102,22 @@ type SessionTreeProps = Pick<
   'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
 > & {
   workspaces: readonly WorkspaceView[]
+  /** Registry-global archive set (hidden rows). */
+  archivedSessionIds: readonly SessionNode['id'][]
   /** Open the browser-owned rename dialog for a real Workspace group. */
   onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
   /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
   onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
   /** Open the browser-owned session rename dialog. */
   onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
+  /** Archive a session (row menu action; the row disappears on the state echo). */
+  onSessionArchive: (sessionId: SessionNode['id']) => void
 }
 
 /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
 function SessionTree({
-  useSessions, startSession, open, forkSession, workspaces,
-  onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t,
+  useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
+  onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t,
 }: SessionTreeProps) {
   const list = useSessions(s => s)
   const current = list.current
@@ -129,8 +133,8 @@ function SessionTree({
     setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
   }, [current, currentGroup])
   const groups = useMemo(
-    () => deriveGroups(list, workspaces, { expandedProjects }),
-    [list, workspaces, expandedProjects],
+    () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }),
+    [list, workspaces, archivedSessionIds, expandedProjects],
   )
   const now = Date.now()
 
@@ -209,6 +213,7 @@ function SessionTree({
                   onOpen={open}
                   onRename={onSessionRename}
                   onFork={forkSession}
+                  onArchive={onSessionArchive}
                   drag={dragProps}
                   t={t}
                 />
@@ -223,9 +228,11 @@ function SessionTree({
 }
 
 /** The flat "In one list" body: every session a top-level row, newest-first. */
-function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 't'>) {
+function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
+  SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
+>) {
   const list = useSessions(s => s)
-  const rows = useMemo(() => deriveFlat(list), [list])
+  const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds])
   const now = Date.now()
   return (
     <div className={clsx(css.treeBody, css.wide)}>
@@ -242,6 +249,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<S
             onOpen={open}
             onRename={onSessionRename}
             onFork={forkSession}
+            onArchive={onSessionArchive}
             t={t}
           />
         ))}
@@ -263,12 +271,14 @@ function SearchResults({
   useSessions,
   open,
   workspaces,
+  archivedSessionIds,
   query,
   remote,
   resultLimit,
   t,
 }: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
   workspaces: readonly WorkspaceView[]
+  archivedSessionIds: readonly SessionNode['id'][]
   query: string
   remote: RemoteSearchState
   resultLimit: number
@@ -278,8 +288,8 @@ function SearchResults({
     ? remote
     : { query, status: 'loading' as const, items: [], hasMore: false }
   const results = useMemo(
-    () => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit),
-    [list, workspaces, query, currentRemote, resultLimit],
+    () => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit),
+    [list, workspaces, query, archivedSessionIds, currentRemote, resultLimit],
   )
   const pending = currentRemote.status === 'loading'
   const failed = currentRemote.status === 'error'
@@ -337,6 +347,7 @@ export function WorkspaceBrowser({
   forkSession,
   renameWorkspace,
   deleteWorkspace,
+  archiveSession,
   insertSessionBefore,
   createWorkspace,
   searchSessions,
@@ -346,6 +357,7 @@ export function WorkspaceBrowser({
   t,
 }: WorkspaceBrowserProps) {
   const workspaces = useWorkspaces(state => state.items)
+  const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
   const groupBy = useStore(s => s.groupBy)
   // The query outlives the tree and the input (both wide-only) so collapsing
   // does not silently drop an in-progress filter.
@@ -475,6 +487,16 @@ export function WorkspaceBrowser({
     setSessionRenameError(null)
   }
 
+  // Archive is dialog-free: not destructive (the log and the accounting slot
+  // remain), so the menu action commits directly; the row disappears when the
+  // archive-set echo lands. Failures are non-fatal console diagnostics, the
+  // same posture as reorder rejections.
+  const onSessionArchive = (sessionId: SessionNode['id']) => {
+    archiveSession(sessionId).catch((reason: unknown) => {
+      console.warn('session archive rejected:', reason)
+    })
+  }
+
   // Delete dialog is separate from the row so a successful removal can
   // unmount that row without tearing down the in-flight confirmation state.
   const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
@@ -597,6 +619,7 @@ export function WorkspaceBrowser({
               useSessions={useSessions}
               open={open}
               workspaces={workspaces}
+              archivedSessionIds={archivedSessionIds}
               query={normalizedQuery}
               remote={remoteSearch}
               resultLimit={searchResultLimit}
@@ -607,15 +630,18 @@ export function WorkspaceBrowser({
             ? (
               <FlatList
                 useSessions={useSessions} open={open} forkSession={forkSession}
-                onSessionRename={onSessionRename} t={t}
+                onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
+                archivedSessionIds={archivedSessionIds} t={t}
               />
             )
             : (
               <SessionTree
                 useSessions={useSessions}
                 onSessionRename={onSessionRename}
+                onSessionArchive={onSessionArchive}
                 forkSession={forkSession}
                 workspaces={workspaces}
+                archivedSessionIds={archivedSessionIds}
                 startSession={startSession}
                 open={open}
                 insertSessionBefore={insertSessionBefore}

+ 6 - 0
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -113,6 +113,12 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
   renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
   /** Delete only a Host Workspace registration; directory and Session logs remain. */
   deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
+  /**
+   * Archive a Session into the registry-global set: hidden from grouping
+   * surfaces, log and accounting slot retained. Archiving the current
+   * session clears the selection into the New Session view state.
+   */
+  archiveSession: (sessionId: SessionId) => Promise<void>
   /**
    * Reorder a session inside its Workspace account (DOM-insertBefore
    * semantics: omitted anchor appends to the end). The view refreshes from

+ 1 - 0
packages/client/ui-workspace/src/client/index.ts

@@ -92,6 +92,7 @@ export function apply(ctx: ClientContext): void {
     },
     renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
     deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
+    archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
     insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
       await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
     },

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

@@ -45,7 +45,7 @@ export const zh = {
   'delete.desc': '将把“{name}”从工作区列表中移除。文件夹与会话记录会保留,其会话将显示在“未分组”下。',
   'delete.pending': '正在删除工作区…',
   'menu.fork': '分叉会话',
-  'menu.deleteSession': '删除会话',
+  'menu.archiveSession': '归档会话',
   'sessions.count.one': '{n} 个会话',
   'sessions.count.other': '{n} 个会话',
   'actions.workspace.aria': '工作区“{name}”的操作',
@@ -108,7 +108,7 @@ export const en = {
   'delete.desc': 'This removes “{name}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.',
   'delete.pending': 'Deleting workspace…',
   'menu.fork': 'Fork session',
-  'menu.deleteSession': 'Delete session',
+  'menu.archiveSession': 'Archive session',
   'sessions.count.one': '{n} session',
   'sessions.count.other': '{n} sessions',
   'actions.workspace.aria': 'Workspace actions for {name}',

+ 13 - 7
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -2,14 +2,14 @@
  * Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
  * all data and callbacks arrive via props. Hover swaps (folder->chevron,
  * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
- * except workspace Rename/Delete and session Rename/Fork; the session and
- * workspace hover cards are suppressed while a menu is open.
+ * except workspace Rename/Delete and session Rename/Fork/Archive; the session
+ * and workspace hover cards are suppressed while a menu is open.
  */
 import { useState } from 'react'
 import clsx from 'clsx'
 import {
-  HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16,
-  IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
+  HoverCard, IconBranchOutline16, IconDownloadOutline16, IconEditOutline16,
+  IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
   IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { WorkspaceBrowserProps } from '../contract/slots.ts'
@@ -243,7 +243,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
   return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
 }
 
-export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag, t }: {
+export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
   node: SessionNode
   currentId: string | undefined
   now: number
@@ -252,6 +252,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
   onRename: (id: SessionNode['id'], currentTitle: string) => void
   /** Fork a session at its last completed turn (row menu action). */
   onFork: (id: SessionNode['id']) => void
+  /** Archive this session (row menu action; commits without a dialog). */
+  onArchive: (id: SessionNode['id']) => void
   /** Present only on draggable rows (workspace-group sessions outside search). */
   drag?: RowDragProps | undefined
   t: RowTranslate
@@ -260,10 +262,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
   const title = displayTitle(node, t)
   const selected = node.id === currentId
   const [menuOpen, setMenuOpen] = useState(false)
+  // Archive replaces the former Delete placeholder: it hides the row through
+  // the registry-global archive set and never touches the session log, so it
+  // is not styled as destructive and needs no confirmation dialog.
   const sessionMenuItems = [
     { id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
     { id: 'fork', label: t('menu.fork'), icon: <IconBranchOutline16 /> },
-    { id: 'delete', label: t('menu.deleteSession'), icon: <IconTrashOutline16 />, danger: true },
+    { id: 'archive', label: t('menu.archiveSession'), icon: <IconDownloadOutline16 /> },
   ]
   // Figma session cell: pad 8, status slot 16, then a 4px title gap.
   const ownRow = (
@@ -310,7 +315,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
           onSelect={(id) => {
             setMenuOpen(false)
             if (id === 'rename') onRename(node.id, row.title)
-            if (id === 'fork') onFork(node.id) // delete stays visual-only.
+            if (id === 'fork') onFork(node.id)
+            if (id === 'archive') onArchive(node.id)
           }}
           portal
           closeOnPointerLeave

+ 29 - 12
packages/client/ui-workspace/src/client/tree.ts

@@ -90,9 +90,13 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
   return a.id < b.id ? -1 : 1
 }
 
-/** Ordinary sessions are visible; among blank sessions, only the current one is visible. */
-function sessionVisible(session: SessionSummary, current: SessionId | undefined): boolean {
-  return !session.blank || session.id === current
+/**
+ * Ordinary sessions are visible; among blank sessions, only the current one
+ * is visible; archived sessions are visible nowhere (their accounting slots
+ * remain, so unarchiving restores position).
+ */
+function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet<SessionId>): boolean {
+  return !archived.has(session.id) && (!session.blank || session.id === current)
 }
 
 /**
@@ -126,7 +130,11 @@ function buildGroup(
  * order, with members resolved from sessionIds in their stored order. Sessions
  * outside every Workspace trail in the recency-ordered Ungrouped bucket.
  */
-function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
+function groupByWorkspace(
+  list: SessionListState,
+  workspaces: readonly WorkspaceView[],
+  archived: ReadonlySet<SessionId>,
+): Group[] {
   const groups: Group[] = []
   const accounted = new Set<SessionId>()
   for (const workspace of workspaces) {
@@ -135,7 +143,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
       const summary = list.byId[id]
       if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
       accounted.add(id)
-      if (!sessionVisible(summary, list.current)) continue
+      if (!sessionVisible(summary, list.current, archived)) continue
       members.push(summary)
     }
     groups.push(buildGroup(
@@ -146,7 +154,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
   const stray = list.ids
     .map(id => list.byId[id])
     .filter((s): s is SessionSummary =>
-      s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current))
+      s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
   if (stray.length > 0) {
     groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
   }
@@ -168,25 +176,29 @@ function sessionNode(s: SessionSummary): SessionNode {
  *
  * Every group shows; sessions populate under expanded groups, preserving
  * Host account order. Blank sessions are excluded except for the selected
- * provisional New Session row. Content search lives outside this derivation
+ * provisional New Session row; archived sessions are excluded everywhere.
+ * Content search lives outside this derivation
  * (see {@link deriveSearchResults}).
  * @param list - sessions list snapshot (`current` feeds containsCurrent).
  * @param workspaces - real workspaces in stable Host order.
+ * @param archivedSessionIds - registry-global archive set.
  * @param view - local expansion arrays.
  * @returns group sections in render order.
  */
 export function deriveGroups(
   list: SessionListState,
   workspaces: readonly WorkspaceView[],
+  archivedSessionIds: readonly SessionId[],
   view: TreeView,
 ): GroupNode[] {
+  const archived = new Set(archivedSessionIds)
   const expandedProjects = new Set(view.expandedProjects)
   const currentGroup = list.current === undefined
     ? undefined
     : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
         ?? UNGROUPED_KEY
   const groups: GroupNode[] = []
-  for (const g of groupByWorkspace(list, workspaces)) {
+  for (const g of groupByWorkspace(list, workspaces, archived)) {
     const expanded = expandedProjects.has(g.key)
     groups.push({
       key: g.key,
@@ -209,13 +221,15 @@ export function deriveGroups(
  * no parent/child adjacency. Content search lives outside this derivation
  * (see {@link deriveSearchResults}).
  * @param list - sessions list snapshot.
+ * @param archivedSessionIds - registry-global archive set.
  * @returns flat rows in render order.
  */
-export function deriveFlat(list: SessionListState): SessionNode[] {
+export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
+  const archived = new Set(archivedSessionIds)
   const rows: SessionSummary[] = []
   for (const id of list.ids) {
     const s = list.byId[id]
-    if (s === undefined || !sessionVisible(s, list.current)) continue
+    if (s === undefined || !sessionVisible(s, list.current, archived)) continue
     rows.push(s)
   }
   rows.sort(byRecency)
@@ -238,6 +252,7 @@ export interface RelativeTime {
  * @param list - session metadata authority.
  * @param workspaces - Workspace membership and display labels.
  * @param query - caller text; surrounding whitespace is ignored.
+ * @param archivedSessionIds - registry-global archive set (members never match).
  * @param content - ranked Host content-search page.
  * @param limit - protocol-owned maximum merged row count.
  * @returns bounded deduplicated flat rows and a refine-query hint bit.
@@ -246,11 +261,13 @@ export function deriveSearchResults(
   list: SessionListState,
   workspaces: readonly WorkspaceView[],
   query: string,
+  archivedSessionIds: readonly SessionId[],
   content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
   limit: number,
 ): SearchResultSet {
   const q = query.trim().toLowerCase()
   if (q === '') return { items: [], hasMore: false }
+  const archived = new Set(archivedSessionIds)
 
   const workspaceBySession = new Map<SessionId, string>()
   for (const workspace of workspaces) {
@@ -270,7 +287,7 @@ export function deriveSearchResults(
     const summary = list.byId[id]
     // Blank placeholders never match a query (their canonical title displays
     // localized, so matching it would tie search to one language).
-    if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue
+    if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue
     if (
       sessionTitle(summary).toLowerCase().includes(q)
       || labelOf(summary).toLowerCase().includes(q)
@@ -290,7 +307,7 @@ export function deriveSearchResults(
   for (const summary of local) include(summary)
   for (const item of content.items) {
     const summary = list.byId[item.sessionId]
-    if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary)
+    if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary)
   }
 
   return {

+ 15 - 11
packages/client/ui-workspace/tests/rows.spec.tsx

@@ -88,7 +88,7 @@ describe('workspace browser rows', () => {
     const onOpen = vi.fn()
     render(
       <SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
-        onRename={vi.fn()} onFork={vi.fn()} t={t} />,
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
     )
 
     const row = screen.getByRole('treeitem')
@@ -157,18 +157,20 @@ describe('workspace browser rows', () => {
     expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
   })
 
-  it('session row menu opens without opening the session and dispatches rename and fork', () => {
+  it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => {
     const onOpen = vi.fn()
     const onRename = vi.fn()
     const onFork = vi.fn()
+    const onArchive = vi.fn()
     const node: SessionNode = {
       id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
     }
     render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
-      onRename={onRename} onFork={onFork} t={t} />)
+      onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
     fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
     expect(onOpen).not.toHaveBeenCalled()
-    expect(screen.getByRole('menuitem', { name: '删除会话' }).className).toMatch(/danger/)
+    // Archive is not destructive (log and accounting slot remain): no danger styling.
+    expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/)
     // Rename dispatches with the current display title (dialog prefill).
     fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
     expect(screen.queryByRole('menu')).toBeNull()
@@ -177,10 +179,12 @@ describe('workspace browser rows', () => {
     fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
     fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
     expect(onFork).toHaveBeenCalledWith(node.id)
-    // Delete stays visual-only.
+    // Archive dispatches without opening the session.
     fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: '删除会话' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
+    expect(onArchive).toHaveBeenCalledWith(node.id)
     expect(onRename).toHaveBeenCalledOnce()
+    expect(onOpen).not.toHaveBeenCalled()
     // Escape closes without selecting (Menu onClose path).
     fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
     fireEvent.keyDown(document, { key: 'Escape' })
@@ -194,7 +198,7 @@ describe('workspace browser rows', () => {
         id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
       }
       render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
-        onRename={vi.fn()} onFork={vi.fn()} t={t} />)
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
       const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
       fireEvent.pointerEnter(wrapper)
       act(() => { vi.advanceTimersByTime(500) })
@@ -220,7 +224,7 @@ describe('workspace browser rows', () => {
         id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
       }
       render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
-        onRename={vi.fn()} onFork={vi.fn()} t={t} />)
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
       fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
       act(() => { vi.advanceTimersByTime(500) })
       expect(screen.getByText('空闲')).toBeTruthy()
@@ -237,7 +241,7 @@ describe('workspace browser rows', () => {
     const inactive = dragProps()
     const { rerender } = render(
       <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
-        onRename={vi.fn()} onFork={vi.fn()} drag={inactive} t={t} />,
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />,
     )
     const row = screen.getByRole('treeitem')
     stubRect(row)
@@ -255,7 +259,7 @@ describe('workspace browser rows', () => {
     const active = dragProps({ active: true, marker: 'before' })
     rerender(
       <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
-        onRename={vi.fn()} onFork={vi.fn()} drag={active} t={t} />,
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />,
     )
     stubRect(screen.getByRole('treeitem'))
     // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
@@ -269,7 +273,7 @@ describe('workspace browser rows', () => {
     const after = dragProps({ active: true, marker: 'after' })
     rerender(
       <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
-        onRename={vi.fn()} onFork={vi.fn()} drag={after} t={t} />,
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />,
     )
     expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
   })

+ 58 - 12
packages/client/ui-workspace/tests/tree.spec.ts

@@ -26,19 +26,21 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
 const view = (expandedProjects: readonly string[] = []) => ({
   expandedProjects,
 })
+const noArchive: readonly SessionId[] = []
+const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
 
 describe('deriveGroups', () => {
   it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
     const sessions = list(summary('newer', 20), summary('older', 10))
     const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
-    const groups = deriveGroups(sessions, workspaces, view(['first']))
+    const groups = deriveGroups(sessions, workspaces, noArchive, view(['first']))
     expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
     expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
   })
 
   it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
     const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
-    const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
+    const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY]))
     expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
     expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
   })
@@ -52,7 +54,7 @@ describe('deriveGroups', () => {
       current: currentBlank.id,
     }
     const groups = deriveGroups(
-      sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']),
+      sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']),
     )
     expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
     const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
@@ -63,7 +65,7 @@ describe('deriveGroups', () => {
     expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
     expect(groups[0]!.sessionCount).toBe(2)
     // A non-current blank stray never surfaces an Ungrouped bucket either.
-    const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view())
+    const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view())
     expect(strayGroups.map(group => group.key)).toEqual(['first'])
   })
 
@@ -80,6 +82,7 @@ describe('deriveGroups', () => {
     const groups = deriveGroups(
       list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
       [],
+      noArchive,
       { expandedProjects: [UNGROUPED_KEY] },
     )
 
@@ -90,7 +93,7 @@ describe('deriveGroups', () => {
     ])
 
     // Equal timestamps use ids as a deterministic tiebreak in either input order.
-    expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]!
+    expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]!
       .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
   })
 
@@ -100,17 +103,32 @@ describe('deriveGroups', () => {
       ids: [sid('present')],
       byId: { [sid('present')]: summary('present', 1) },
     }
-    const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project']))
+    const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project']))
     expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
   })
 
+  it('hides archived sessions from workspace groups and Ungrouped', () => {
+    const kept = summary('kept', 1, '/projects/first')
+    const gone = summary('gone', 2, '/projects/first')
+    const looseGone = summary('loose-gone', 3, '/other')
+    const sessions = list(kept, gone, looseGone)
+    const groups = deriveGroups(
+      sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]),
+    )
+    // The archived member drops from its group AND the archived stray never
+    // surfaces an Ungrouped bucket; counts follow the visible rows.
+    expect(groups.map(group => group.key)).toEqual(['first'])
+    expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id])
+    expect(groups[0]!.sessionCount).toBe(1)
+  })
+
   it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
     const owned = summary('owned', 1)
     const loose = summary('loose', 2)
     const ws = workspace('project', ['owned'])
-    const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view())
+    const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view())
     expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
-    const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view())
+    const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view())
     expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
   })
 })
@@ -121,13 +139,13 @@ describe('deriveFlat', () => {
     const child = { ...summary('child', 30), parentId: parent.id }
     const tieB = summary('tie-b', 20)
     const tieA = summary('tie-a', 20)
-    const rows = deriveFlat(list(parent, child, tieB, tieA))
+    const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive)
     expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
   })
 
   it('tolerates ids whose summary has not landed yet', () => {
     const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
-    expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')])
+    expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')])
   })
 
   it('shows only the current blank session and excludes blanks from search', () => {
@@ -137,11 +155,35 @@ describe('deriveFlat', () => {
       ...list(summary('real', 1), currentBlank, staleBlank),
       current: currentBlank.id,
     }
-    const rows = deriveFlat(sessions)
+    const rows = deriveFlat(sessions, noArchive)
     expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
     expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
     expect(rows.map(row => row.blank)).toEqual([true, false])
   })
+
+  it('hides archived sessions in flat mode', () => {
+    const kept = summary('kept', 1)
+    const gone = summary('gone', 2)
+    expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id])
+  })
+})
+
+describe('deriveSearchResults archive filtering', () => {
+  it('archived sessions never match — not by title and not via a backend content hit', () => {
+    const hit = summary('hit', 2)
+    hit.displayTitle = 'Needle row'
+    const gone = summary('gone', 1)
+    gone.displayTitle = 'Needle archived'
+    const result = deriveSearchResults(
+      list(hit, gone),
+      [],
+      'needle',
+      archived('gone'),
+      { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
+      10,
+    )
+    expect(result.items.map(item => item.id)).toEqual([hit.id])
+  })
 })
 
 describe('deriveSearchResults', () => {
@@ -160,6 +202,7 @@ describe('deriveSearchResults', () => {
         workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
       ],
       ' NEEDLE ',
+      noArchive,
       {
         items: [
           { sessionId: contentHit.id, snippet: 'body needle excerpt' },
@@ -212,6 +255,7 @@ describe('deriveSearchResults', () => {
       sessions,
       [workspace('first', ['opaque-current', 'new session stale'])],
       'new session',
+      noArchive,
       {
         items: [
           { sessionId: staleBlank.id, snippet: 'stale body' },
@@ -234,6 +278,7 @@ describe('deriveSearchResults', () => {
       list(...rows),
       [],
       'needle',
+      noArchive,
       { items: [], hasMore: false },
       3,
     )
@@ -244,12 +289,13 @@ describe('deriveSearchResults', () => {
       list(summary('body', 1)),
       [],
       'needle',
+      noArchive,
       { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
       3,
     )
     expect(backendMore.items).toHaveLength(1)
     expect(backendMore.hasMore).toBe(true)
-    expect(deriveSearchResults(list(), [], '  ', { items: [], hasMore: true }, 3))
+    expect(deriveSearchResults(list(), [], '  ', noArchive, { items: [], hasMore: true }, 3))
       .toEqual({ items: [], hasMore: false })
   })
 })

+ 47 - 2
packages/client/ui-workspace/tests/workspace-browser.spec.tsx

@@ -35,8 +35,8 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
   workspaceId: wid(id), path: `/projects/${id}`, title,
   sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
 })
-const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
-  items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
+const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: ReadonlySet<SessionId> = new Set()): WorkspaceListState => ({
+  items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
   recentWorkspaceId: items[0]?.workspaceId,
 })
 function hook<T>(snapshot: T) {
@@ -68,6 +68,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     forkSession: vi.fn(),
     renameWorkspace: vi.fn(async () => {}),
     deleteWorkspace: vi.fn(async () => {}),
+    archiveSession: vi.fn(async () => {}),
     insertSessionBefore: vi.fn(async () => {}),
     createWorkspace: vi.fn(async () => workspace('created', [])),
     useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
@@ -135,6 +136,50 @@ describe('WorkspaceBrowser', () => {
     expect(screen.queryByText('alpha-s')).toBeNull()
   })
 
+  it('archives a session from the row menu and hides archived rows in both modes', async () => {
+    const archiveSession = vi.fn(async () => {})
+    const b = mount({
+      useSessions: hook(sessionState([summary('kept-s', 2), summary('gone-s', 1)])),
+      useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])])),
+      archiveSession,
+    })
+    fireEvent.click(screen.getByText('alpha'))
+    fireEvent.click(screen.getByRole('button', { name: '会话“gone-s”的操作' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
+    expect(archiveSession).toHaveBeenCalledWith(sid('gone-s'))
+
+    // The archive-set echo hides the row in grouped mode (count included) and flat mode.
+    rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], new Set([sid('gone-s')]))) })
+    expect(screen.queryByText('gone-s')).toBeNull()
+    expect(screen.getByText('1 个会话')).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
+    expect(screen.getByText('kept-s')).toBeTruthy()
+    expect(screen.queryByText('gone-s')).toBeNull()
+  })
+
+  it('logs and keeps the tree when the archive call rejects', async () => {
+    const rejection = new Error('archive exploded')
+    const archiveSession = vi.fn(async () => { throw rejection })
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+    try {
+      mount({
+        useSessions: hook(sessionState([summary('alpha-s', 1)])),
+        useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
+        archiveSession,
+      })
+      fireEvent.click(screen.getByText('alpha'))
+      fireEvent.click(screen.getByRole('button', { name: '会话“alpha-s”的操作' }))
+      fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
+      await Promise.resolve()
+      await Promise.resolve()
+      expect(warn).toHaveBeenCalledWith('session archive rejected:', rejection)
+      expect(screen.getByText('alpha-s')).toBeTruthy()
+    } finally {
+      warn.mockRestore()
+    }
+  })
+
   it('renders a fork child as a top-level row without a session twist', () => {
     const parent = summary('parent-s', 2)
     const child = { ...summary('child-s', 1), parentId: parent.id }

+ 1 - 1
packages/client/ui-workspace/tests/workspace-picker.spec.tsx

@@ -32,7 +32,7 @@ const sessions: SessionListState = {
   ids: [], byId: {}, current: undefined, phase: 'ready',
 }
 const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
-  items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
+  items, archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true,
   recentWorkspaceId: items[0]?.workspaceId,
 })
 function anchor(): { current: HTMLElement } {

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio