Преглед изворни кода

feat(workspace): restore archived sessions from a settings page

Sessions could be archived from the sidebar row menu but never restored:
the registry-global archive set was a one-way display filter with no
inverse RPC and no surface listing its members.

WorkspaceRegistry.unarchiveSession drops one id from archivedSessionIds in
one durable write on the same operation chain as archive, with no
session-existence probe because a removal cannot introduce an unknown
referent. The Workspace controller exposes it as workspace.unarchiveSession
returning the complete archive set, which the existing archived follow
increment already propagates, so no frame type and no persisted field
changes.

The new client package dsh-client-ui-settings-unarchive-sessions owns the
restore surface: one localized settings.section page listing the archive
set joined with loaded session summaries, newest archive first, searchable
by title or workspace, with one Unarchive action per row.
Tianyi Cui пре 3 недеља
родитељ
комит
5f773a0ded
75 измењених фајлова са 1620 додато и 49 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml
  5. 45 0
      .agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md
  6. 45 0
      .agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md
  7. 167 0
      apps/web/tests/session-unarchive.e2e.ts
  8. 1 0
      apps/web/tsconfig.json
  9. 2 2
      docs/config-catalog.i18n.yaml
  10. 1 0
      docs/config-catalog.md
  11. 1 0
      docs/config-catalog.zh.md
  12. 2 2
      docs/module-graph.i18n.yaml
  13. 2 0
      docs/module-graph.md
  14. 2 0
      docs/module-graph.zh.md
  15. 2 2
      docs/subsystems/web-client.i18n.yaml
  16. 1 1
      docs/subsystems/web-client.md
  17. 1 1
      docs/subsystems/web-client.zh.md
  18. 2 2
      docs/subsystems/workspace.i18n.yaml
  19. 19 0
      docs/subsystems/workspace.md
  20. 19 0
      docs/subsystems/workspace.zh.md
  21. 8 0
      packages/api/session-controller/tests/fake-api.client.ts
  22. 2 2
      packages/api/workspace-controller/README.i18n.yaml
  23. 1 1
      packages/api/workspace-controller/README.md
  24. 1 1
      packages/api/workspace-controller/README.zh.md
  25. 14 0
      packages/api/workspace-controller/src/client/model.ts
  26. 10 0
      packages/api/workspace-controller/src/client/service.ts
  27. 13 0
      packages/api/workspace-controller/src/commands.ts
  28. 11 0
      packages/api/workspace-controller/src/index.ts
  29. 5 0
      packages/api/workspace-controller/src/types.ts
  30. 20 0
      packages/api/workspace-controller/tests/model.client.spec.ts
  31. 2 0
      packages/api/workspace-controller/tests/remote/workspace.client.ts
  32. 5 0
      packages/api/workspace-controller/tests/transport.client.spec.ts
  33. 15 0
      packages/api/workspace-controller/tests/workspace-controller.host.spec.ts
  34. 3 0
      packages/bundle/web-app/cordis.patch.yml
  35. 1 0
      packages/bundle/web-app/package.json
  36. 12 0
      packages/client/connection/src/client/fixture.ts
  37. 14 0
      packages/client/connection/tests/fixture.client.spec.ts
  38. 3 1
      packages/client/ui-settings-general/src/client/SettingsRoot.tsx
  39. 6 5
      packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
  40. 3 3
      packages/client/ui-settings-general/tests/shell.client.spec.ts
  41. 6 0
      packages/client/ui-settings-unarchive-sessions/README.i18n.yaml
  42. 112 0
      packages/client/ui-settings-unarchive-sessions/README.md
  43. 112 0
      packages/client/ui-settings-unarchive-sessions/README.zh.md
  44. 73 0
      packages/client/ui-settings-unarchive-sessions/package.json
  45. 92 0
      packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css
  46. 130 0
      packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx
  47. 49 0
      packages/client/ui-settings-unarchive-sessions/src/client/index.ts
  48. 40 0
      packages/client/ui-settings-unarchive-sessions/src/client/locales.ts
  49. 6 0
      packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts
  50. 4 0
      packages/client/ui-settings-unarchive-sessions/src/index.ts
  51. 85 0
      packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx
  52. 149 0
      packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx
  53. 39 0
      packages/client/ui-settings-unarchive-sessions/tsconfig.json
  54. 3 0
      packages/client/ui-settings-unarchive-sessions/tsdown.config.ts
  55. 2 2
      packages/client/ui-workspace/README.i18n.yaml
  56. 1 1
      packages/client/ui-workspace/README.md
  57. 1 1
      packages/client/ui-workspace/README.zh.md
  58. 9 0
      packages/client/ui-workspace/src/client/navigation.ts
  59. 25 0
      packages/client/ui-workspace/tests/workspaces-service.client.spec.ts
  60. 10 0
      packages/extensions/cordis-client-runner/src/client/api-catalog.ts
  61. 16 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  62. 17 0
      packages/test-support/client-runtime/src/workspaces.ts
  63. 9 1
      packages/test-support/client-runtime/tests/runtime.client.spec.tsx
  64. 2 2
      packages/workspace/workspace/README.i18n.yaml
  65. 6 6
      packages/workspace/workspace/README.md
  66. 6 6
      packages/workspace/workspace/README.zh.md
  67. 23 0
      packages/workspace/workspace/src/index.ts
  68. 60 0
      packages/workspace/workspace/tests/workspace.spec.ts
  69. 54 0
      pnpm-lock.yaml
  70. 1 0
      scripts/gen-cordis-catalog.ts
  71. 3 3
      scripts/gen-cordis-inspect-catalog.ts
  72. 1 0
      scripts/verify-package-readme-model-experience.ts
  73. 1 0
      tsconfig.base.json
  74. 1 0
      tsconfig.client.json
  75. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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 .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
-2026-08-18-session-history-and-event-transport.md: 9a593ca9bfe4a80657d3eeeaa89cf0dcc73dd829
-2026-08-18-session-history-and-event-transport.zh.md: a7f8b8f27153a1814ca65bbf9042d78717b50849
+2026-08-18-session-history-and-event-transport.md: 8781ea265798ff200a6e0a58542b7d03693d8bd3
+2026-08-18-session-history-and-event-transport.zh.md: 1603f5aed8adb7a42fecab92511176d2147be5c3

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md

@@ -220,7 +220,7 @@ Session-list `updatedAt` is `max(header.createdAt, sessionListMetadata.lastPromp
 
 `packages/api/workspace-controller` provides Host `ctx.workspaceController` and the generated `ctx.remote.workspace` namespace.
 
-It owns create, rename, delete, insertBefore, insertSessionBefore, archiveSession, and `follow`. Workspace registry remains the durable source of truth; the Controller owns Remote commands, projection, and error mapping.
+It owns create, rename, delete, insertBefore, insertSessionBefore, archiveSession, unarchiveSession, and `follow`. Workspace registry remains the durable source of truth; the Controller owns Remote commands, projection, and error mapping.
 
 `WorkspaceFeed` synchronously observes storage `domain/changed`, and each follow generation emits a complete baseline before `upsert`, `remove`, `order`, and `archived` deltas.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md

@@ -220,7 +220,7 @@ Session 列表的 `updatedAt` 取 `max(header.createdAt, sessionListMetadata.las
 
 `packages/api/workspace-controller` 提供 Host `ctx.workspaceController` 与生成的 `ctx.remote.workspace` namespace。
 
-它拥有 create、rename、delete、insertBefore、insertSessionBefore、archiveSession 与 `follow`。Workspace registry 仍是持久事实来源,Controller 负责 Remote 命令、投影和错误映射。
+它拥有 create、rename、delete、insertBefore、insertSessionBefore、archiveSession、unarchiveSession 与 `follow`。Workspace registry 仍是持久事实来源,Controller 负责 Remote 命令、投影和错误映射。
 
 `WorkspaceFeed` 同步观察 storage `domain/changed`,并为每个 follow generation 先发送完整 baseline,再发送 `upsert`、`remove`、`order` 与 `archived` 增量。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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 .agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md
+2026-09-12-session-unarchive-settings-page.md: 9f09b6a9a7a161c97ff71360eba1568fb090dd92
+2026-09-12-session-unarchive-settings-page.zh.md: 2fb16f546b6e616d19a2402083c226627ea2703a

+ 45 - 0
.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md

@@ -0,0 +1,45 @@
+# Agent Note: Session unarchive Settings page
+
+Status: implemented
+
+English | [中文](2026-09-12-session-unarchive-settings-page.zh.md)
+
+## Problem
+
+Archiving a Session removed it from every Workspace grouping surface, and nothing brought it back. The archive set is a durable display filter, so a hidden Session kept its log, its Workspace accounting slot, and its position, but the only recovered route was editing the domain state by hand. The archived feature record predicted the restore surface as "one UI surface plus one inverse RPC"; both now exist, and the Client had no place that listed archived Sessions at all.
+
+## Decision
+
+`WorkspaceRegistry.unarchiveSession(sessionId)` drops one id from the registry-global `archivedSessionIds` set in a single durable `setState`, running on the same operation chain as archive, create, and delete. The idempotent check-then-write pair sits inside one chain slot, so a concurrent archive cannot interleave and a lost race resolves as a no-op. An id that is not archived resolves without writing.
+
+Unarchiving runs no session-existence probe. Archive verifies that the Session is live or persisted because it adds a reference that must resolve; unarchive only removes an id, so it cannot introduce an unknown referent and an entry whose Session is gone still restores. `@Remote('unarchiveSession')` on `WorkspaceController` returns the complete `WorkspaceArchiveValue`, matching `archiveSession`, and `IWorkspaces.unarchiveSession` plus `UiWorkspace.unarchiveSession` carry the verb to the browser.
+
+The new Web Settings page `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` owns the surface. It registers one localized `settings.section` contribution with id `archived-sessions` at nav order 25, joins the archive set from `useWorkspaces` with the loaded Session summaries from `useSessions`, lists the newest archive first with the owning Workspace title or the ungrouped label and a relative last-activity time, filters by title or Workspace name, and offers one Unarchive action per row. A rejected write is logged as a console diagnostic and leaves the row for another attempt. An archive entry whose Session summary is missing produces no row, so the page never renders an action that cannot restore anything.
+
+Restoring is a page whose subject is archived Sessions, and the archived Sessions themselves are hidden from every grouping surface, so no Session row can host the action. The archived-sessions page joins the Settings navigation beside General, Models, and Plugins, whose rail already carries the archive glyph this page declares in `SettingsRoot`.
+
+The `{ type: 'archived', archivedSessionIds }` follow increment already carries the complete set, so a restore reuses it: the Remote answer installs locally, and every other Client converges through the same increment. No frame type, persisted field, or `SESSION_FORMAT_VERSION` change accompanies the verb.
+
+## Alternatives considered
+
+**An Unarchive action in the Session row menu.** The row menu owns Archive, but a restored Session has no visible row until it is restored, so the action would have nowhere to live in the state that needs it; a disabled entry on every row would be decoration without a subject.
+
+**A second frame type carrying a removal delta.** The existing `archived` increment is a complete-set replacement, so a removal delta would add a redundant representation that every consumer would have to merge; the full-snapshot posture is what lets the archive echo be reused unchanged.
+
+**Rendering an archive entry whose Session is gone as a disabled row.** Such a row could explain the gap but not restore anything, and the registry and the Remote still accept the id, so the page lists what it can act on and leaves the orphan id to a future cleanup surface.
+
+**A Session-existence probe on unarchive, mirroring archive.** The check exists to keep added references resolvable, and a removal cannot break that invariant; probing would only turn restoring a deleted history into a failure with nothing to repair.
+
+## Consequences
+
+The archive set stays the only durable state a restore rewrites; the `workspace` domain version, the Session log, and the Workspace accounting slot are untouched, and a restored Session returns to its recorded position. Archive and unarchive now enforce different Session checks, a deliberate asymmetry recorded in the package README limitations.
+
+The restore surface is bounded by what the page can display: an archived id whose summary is not loaded has no row and no Unarchive action even though the registry method and the Remote accept it. Restoring through automation stays available, and a future cleanup surface can address the remaining orphan entries.
+
+## Testing
+
+`packages/workspace/workspace/tests/workspace.spec.ts` pins the registry method: removal keeps the surviving archive order, the accounting slot stays, a repeat and a never-archived id neither rewrite the medium nor emit a change, an entry whose Session is gone resolves without a persistence listing, and the surviving set reloads across a restart. `packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` pins the verb's idempotent complete-set answer and the unchanged `archived` follow increment, and `packages/api/workspace-controller/tests/model.client.spec.ts` pins the Client model echo and its refusal to install a failed answer. `packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` pins newest-first ordering, the ungrouped label, the hidden entry whose Session is gone, the reading and empty states, search, and the console diagnostic on rejection, while `tests/browser-plugin.client.spec.tsx` pins the section registration.
+
+## Related
+
+- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set, the follow increment, and the predicted restore surface.

+ 45 - 0
.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md

@@ -0,0 +1,45 @@
+# Agent Note: Session unarchive Settings page
+
+Status: implemented
+
+[English](2026-09-12-session-unarchive-settings-page.md) | 中文
+
+## 问题
+
+归档会话会把它从每一个 Workspace 分组界面中移除,而没有任何东西能把它带回来。归档集合是持久的显示过滤器,因此被隐藏的会话保留其日志、Workspace 记账位置与所在位置,但唯一的恢复途径是手工编辑领域状态。归档功能记录把恢复界面预测为「一个 UI 界面加一个反向 RPC」;两者如今都已存在,而 Client 此前根本没有列出已归档会话的地方。
+
+## 决策
+
+`WorkspaceRegistry.unarchiveSession(sessionId)` 在一次持久化 `setState` 中把某个 id 从注册表全局的 `archivedSessionIds` 集合里移除,并与 archive、create、delete 跑在同一条操作链上。幂等的「先检查再写入」位于同一个链槽内,因此并发归档无法插入其间,输掉竞态的一方会解析为无操作。未被归档的 id 不写盘即解析完成。
+
+取消归档不做会话存在性探测。归档会校验会话处于实时或已持久化状态,因为它加入的引用必须可解析;取消归档只是移除一个 id,因此不可能引入未知引用,会话已不存在的条目也仍然能恢复。`WorkspaceController` 上的 `@Remote('unarchiveSession')` 返回完整的 `WorkspaceArchiveValue`,与 `archiveSession` 一致,而 `IWorkspaces.unarchiveSession` 与 `UiWorkspace.unarchiveSession` 把该动词带到浏览器。
+
+新的 Web 设置页 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` 拥有该界面。它注册一个 id 为 `archived-sessions`、导航顺序为 25 的本地化 `settings.section` 贡献,把来自 `useWorkspaces` 的归档集合与来自 `useSessions` 的已加载 Session 摘要合并,按归档时间由新到旧列出,并显示为其记账的 Workspace 标题或未分组标签,以及相对最近活动时间;它按标题或 Workspace 名称过滤,并为每行提供一个取消归档操作。写入被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。缺失 Session 摘要的归档条目不产生行,因此该页面绝不会渲染无法恢复任何东西的操作。
+
+恢复操作所在的页面以已归档会话为主题,而已归档会话本身从每一个分组界面中隐藏,因此没有任何 Session 行能承载该操作。「已归档会话」页在设置导航中与「通用」「模型」「插件」并列,而该导航轨道本就带有本页在 `SettingsRoot` 中声明的归档字形。
+
+`{ type: 'archived', archivedSessionIds }` follow 增量本就携带完整集合,因此恢复复用它:Remote 应答在本地安装,其他每个 Client 都通过同一个增量收敛。该动词不伴随任何帧类型、持久字段或 `SESSION_FORMAT_VERSION` 变更。
+
+## 考虑过的替代方案
+
+**在 Session 行菜单放一个取消归档操作。** 行菜单拥有 Archive,但被恢复的会话在恢复之前没有可见的行,因此该操作在真正需要它的状态里无处安放;给每一行加一个禁用条目只是没有主体的装饰。
+
+**新增一种携带移除增量的帧类型。** 现有的 `archived` 增量是完整集合替换,因此移除增量会增加一种每个消费方都必须合并的冗余表示;正是全快照姿态让归档回声得以原样复用。
+
+**把会话已不存在的归档条目渲染为禁用行。** 这样的行可以解释缺口,却无法恢复任何东西,而注册表与 Remote 仍然接受该 id,因此该页面只列出它能操作的条目,把遗留 id 留给未来的清理界面。
+
+**像归档一样,在取消归档时做会话存在性探测。** 该检查的存在是为了让新增的引用保持可解析,而移除不可能破坏这一不变式;探测只会把恢复已删除的历史变成一次无从修复的失败。
+
+## 后果
+
+归档集合仍是恢复唯一重写的持久状态;`workspace` 领域版本、会话日志与 Workspace 记账位置都不受影响,被恢复的会话回到其记录的位置。归档与取消归档现在执行不同的会话校验,这是刻意的不对称,并记录在包 README 的限制中。
+
+恢复界面受该页面能显示的内容限制:摘要未加载的已归档 id 没有行也没有取消归档操作,尽管注册表方法与 Remote 都接受它。通过自动化恢复仍然可用,未来的清理界面可以处理剩余的遗留条目。
+
+## 测试
+
+`packages/workspace/workspace/tests/workspace.spec.ts` 固定注册表方法:移除后幸存的归档顺序保持不变、记账位置仍在、重复取消归档与从未归档的 id 既不重写介质也不发出变更、会话已不存在的条目无需持久化列表即可解析、幸存集合在重启后重新加载。`packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` 固定该动词幂等的完整集合应答与不变的 `archived` follow 增量,`packages/api/workspace-controller/tests/model.client.spec.ts` 固定 Client model 的回声及其拒绝安装失败应答的行为。`packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` 固定由新到旧的排序、未分组标签、会话已不存在条目的隐藏、读取与空状态、搜索,以及被拒绝时的 console 诊断,`tests/browser-plugin.client.spec.tsx` 则固定分区注册。
+
+## 相关
+
+- [会话归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md)——归档集合、follow 增量与被预测的恢复界面的冻结记录。

+ 167 - 0
apps/web/tests/session-unarchive.e2e.ts

@@ -0,0 +1,167 @@
+// Web e2e scenario: the archived-session Settings page restores a Session
+// through the real wire. One cold Session seeded from another scenario's
+// committed fixture (seeded-history, reused read-only — no new recording) is
+// archived from its sidebar row menu, which hides the row and writes the id
+// into the registry-global archive set; the Settings section lists that id,
+// and its own Unarchive button drops the id again, so the durable set empties,
+// the page row disappears, and the Session row returns to the sidebar — still
+// there after a reload that rebuilds the archive set from the host baseline.
+// Zero model calls: workspace.archiveSession and workspace.unarchiveSession
+// are host RPCs and the page reads already-loaded Session summaries, so no
+// replay fixture mounts and a stray model stream fails loud.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Locator, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import {
+  acknowledgeReloadConnectionLoss, launchWebScaffold, seedSession, watchConsole, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+// The seed is another scenario's committed fixture, reused read-only: this
+// spec needs any one cold Session row, not new recorded content.
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
+const SEED_ID = 'session-unarchive-web-e2e'
+
+describe('web e2e: archived sessions are restored from the Settings page', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  /**
+   * Expand the Ungrouped group when it renders collapsed so its rows are
+   * addressable, then return the section holding them.
+   * @returns the expanded Ungrouped section locator.
+   */
+  async function ungroupedSection(): Promise<Locator> {
+    const header = page.getByText('Ungrouped', { exact: true })
+    const groupRow = header.locator('..').locator('..')
+    await expect.poll(async () => {
+      if (await groupRow.count() === 0) return 'absent'
+      if (await groupRow.getAttribute('aria-expanded') !== 'true') {
+        await header.click()
+        return 'collapsed'
+      }
+      return 'expanded'
+    }, { timeout: 15_000 }).toBe('expanded')
+    return groupRow.locator('..')
+  }
+
+  /**
+   * Reveal and click a row action, re-hovering if a projection update replaces
+   * the row before its hover-only button becomes visible.
+   * @param row - the row owning the action.
+   * @param name - accessible name of the action button.
+   */
+  async function clickHoverAction(row: Locator, name: string): Promise<void> {
+    const button = row.getByRole('button', { name })
+    await expect.poll(async () => {
+      await row.hover()
+      return await button.isVisible()
+    }, { timeout: 10_000 }).toBe(true)
+    await button.click()
+  }
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    // Seed one cold session; with no Workspace registered it is the sidebar's
+    // only row, in the Ungrouped bucket.
+    await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('archives the seed, unarchives it from Settings, and keeps it restored across reload', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-session-unarchive'))
+    // Select the only visible Session, then give it a user-owned title: the
+    // locator binds to the seed's own copy on both sides of the round trip.
+    const seededRow = (await ungroupedSection()).locator('[role="treeitem"]')
+      .filter({ has: page.locator('button[aria-label^="Session actions for "]') })
+    await expect.poll(() => seededRow.count(), { timeout: 10_000 }).toBe(1)
+    await seededRow.click()
+    await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
+    const { title } = await scaffold.ctx.sessionController.rename({
+      sessionId: SessionId(SEED_ID), title: `Unarchive target ${SEED_ID}`,
+    })
+    const sessionRow = page.getByRole('treeitem').filter({ has: page.getByText(title, { exact: true }) })
+    await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(1)
+    await expect.poll(() => sessionRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
+
+    // Archive from the row menu: no confirmation dialog, and losing the last
+    // visible Session withdraws the whole Ungrouped bucket.
+    await clickHoverAction(sessionRow, `Session actions for ${title}`)
+    await page.getByRole('menuitem', { name: 'Archive session' }).click()
+    await expect.poll(() => sessionRow.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.workspaceRegistry.archivedSessionIds]).toEqual([SessionId(SEED_ID)])
+    expect((await scaffold.ctx.sessionPersistence.list()).map(snapshot => snapshot.header.id))
+      .toContain(SessionId(SEED_ID))
+
+    // The Settings page lists the archived Session behind its own search box.
+    await page.getByRole('button', { name: 'Settings', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: 'Settings' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: 'Archived sessions' }).click()
+    const search = dialog.getByRole('searchbox', { name: 'Search archived sessions' })
+    await search.waitFor({ timeout: 10_000 })
+    const unarchiveRow = dialog.getByRole('button', { name: `Unarchive ${title}` })
+    await expect.poll(() => unarchiveRow.count(), { timeout: 10_000 }).toBe(1)
+    expect(await dialog.getByText(title, { exact: true }).count()).toBe(1)
+    expect(await dialog.getByText(/^Ungrouped · /).count()).toBe(1)
+    expect(await dialog.getByText('No archived sessions.', { exact: true }).count()).toBe(0)
+    // The search box filters these rows in both directions.
+    await search.fill('zzz-no-such-archived-session')
+    await expect.poll(
+      () => dialog.getByText('No matching sessions.', { exact: true }).count(),
+      { timeout: 5_000 },
+    ).toBe(1)
+    expect(await unarchiveRow.count()).toBe(0)
+    await search.fill(title)
+    await expect.poll(() => unarchiveRow.count(), { timeout: 5_000 }).toBe(1)
+    expect(await dialog.getByText('No matching sessions.', { exact: true }).count()).toBe(0)
+    await search.fill('')
+    await expect.poll(() => unarchiveRow.count(), { timeout: 5_000 }).toBe(1)
+
+    // Unarchive: the durable set empties (the row's own RPC), the page row
+    // leaves with the empty status, and the Session row is back in the sidebar.
+    await unarchiveRow.click()
+    await expect.poll(
+      () => [...scaffold.ctx.workspaceRegistry.archivedSessionIds],
+      { timeout: 10_000 },
+    ).toEqual([])
+    await expect.poll(() => unarchiveRow.count(), { timeout: 10_000 }).toBe(0)
+    expect(await dialog.getByText(title, { exact: true }).count()).toBe(0)
+    await expect.poll(
+      () => dialog.getByText('No archived sessions.', { exact: true }).count(),
+      { timeout: 10_000 },
+    ).toBe(1)
+    await dialog.getByRole('button', { name: 'Close' }).last().click()
+    await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
+    await ungroupedSection()
+    await expect.poll(() => sessionRow.count(), { timeout: 15_000 }).toBe(1)
+
+    // Reload: the restored row is rebuilt from the host baseline, so the
+    // unarchive was durable and not merely client state.
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    await ungroupedSection()
+    await expect.poll(() => sessionRow.count(), { timeout: 15_000 }).toBe(1)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 120_000)
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -60,6 +60,7 @@
     "tests/workspace-new-session-folding.e2e.ts",
     "tests/workspace-management.e2e.ts",
     "tests/workspace-recency.e2e.ts",
+    "tests/session-unarchive.e2e.ts",
     "tests/replay-round-trip.e2e.ts",
     "tests/hmr-live.e2e.ts",
     "tests/preview-boot.e2e.ts",

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: b1cb9db56757db61bd6cdd05ac43dc1c6b7378f4
-config-catalog.zh.md: e1bd6411114e0f82c229d1df9c5844a6ee309a51
+config-catalog.md: bb5454ba6d645c3c7ccce7db69a8b40bdec42583
+config-catalog.zh.md: ab611408eca7c1645967335a700b05517501d1a0

+ 1 - 0
docs/config-catalog.md

@@ -3514,6 +3514,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -3516,6 +3516,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: ee9b15ebe1576e689cd11d76b296125a47597000
-module-graph.zh.md: 80d6777d4d79fd41afa66575c3af038d8033133d
+module-graph.md: ad95055805e0015312f58c8ab41d639d57ca7d10
+module-graph.zh.md: 7f41b141d1eceaf12feda96e45d5d70d171704c2

+ 2 - 0
docs/module-graph.md

@@ -172,6 +172,7 @@ flowchart TD
     pkg_client_ui_settings_models["client-ui-settings-models"]
     pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
     pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
+    pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
@@ -1299,6 +1300,7 @@ flowchart TD
 | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — |
 | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
+| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |

+ 2 - 0
docs/module-graph.zh.md

@@ -174,6 +174,7 @@ flowchart TD
     pkg_client_ui_settings_models["client-ui-settings-models"]
     pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
     pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
+    pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
@@ -1301,6 +1302,7 @@ flowchart TD
 | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — |
 | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
+| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |

+ 2 - 2
docs/subsystems/web-client.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 docs/subsystems/web-client.md
-web-client.md: 7e7f483131585dc66c45dbb147d40e848f40d6d1
-web-client.zh.md: 603af505dbd1fc6e2f9fd3bf9542ce19cb63732f
+web-client.md: 4e1b4948f8afe8d61f21a1f1c5cb08d6f341d7f5
+web-client.zh.md: 6cc3c7c79bca840143ccd9cda02b3542247ead4c

+ 1 - 1
docs/subsystems/web-client.md

@@ -47,7 +47,7 @@ The durable event path opens `follow()`, whose first frame contains the current
 
 ### Workspaces
 
-[`api/workspace-controller`](../../packages/api/workspace-controller/README.md) keeps Workspace mutation policy and the authoritative follow feed on the Host. `ClientWorkspaceModel` owns the browser rows, order, archived Session ids, command echoes, and stream/unary race resolution. Every stream generation starts with a complete baseline followed by `upsert`, `remove`, `order`, and `archived` increments; reconnect replaces the model from the new baseline. `WorkspaceController` exposes that model as `ctx.workspaces`, while `ui-workspace` contributes `useWorkspaces` and navigation callbacks to the UI.
+[`api/workspace-controller`](../../packages/api/workspace-controller/README.md) keeps Workspace mutation policy and the authoritative follow feed on the Host. `ClientWorkspaceModel` owns the browser rows, order, archived Session ids, command echoes, and stream/unary race resolution. Every stream generation starts with a complete baseline followed by `upsert`, `remove`, `order`, and `archived` increments; reconnect replaces the model from the new baseline. `WorkspaceController` exposes that model as `ctx.workspaces`, while `ui-workspace` contributes `useWorkspaces` and navigation callbacks to the UI. The archived Session ids filter every grouping surface and feed the archived-sessions Settings page, which joins the set with the loaded Session summaries and offers one Unarchive action per row; a restore calls the `workspace.unarchiveSession` Remote, and the complete returned set reaches every Client through the `archived` increment.
 
 This pairing is not a second source of business truth. Host controllers decide durable state and mutation outcomes; Client models maintain the latest usable local projection, preserve object identity where useful to rendering, and encode how delayed responses and replacement baselines merge.
 

+ 1 - 1
docs/subsystems/web-client.zh.md

@@ -47,7 +47,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe
 
 ### Workspaces
 
-[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace mutation policy 与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器侧 row、order、archived Session id、command echo,以及 stream/unary 竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order` 和 `archived` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,而 `ui-workspace` 向 UI 提供 `useWorkspaces` 与 navigation callback。
+[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace mutation policy 与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器侧 row、order、archived Session id、command echo,以及 stream/unary 竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order` 和 `archived` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,而 `ui-workspace` 向 UI 提供 `useWorkspaces` 与 navigation callback。archived Session id 过滤每一个分组视图,并驱动「已归档会话」设置页;该页把该集合与已加载的 Session summary 合并,为每行提供一个取消归档操作。恢复会调用 `workspace.unarchiveSession` Remote,返回的完整集合则经 `archived` increment 到达每个 Client。
 
 这种配对不会产生第二份业务真相。Host controller 决定持久状态与 mutation outcome;Client model 维护最新可用的本地 projection,在有利于渲染时保持 object identity,并明确 delayed response 与 replacement baseline 的合并规则。
 

+ 2 - 2
docs/subsystems/workspace.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 docs/subsystems/workspace.md
-workspace.md: b8b531497b33ddc9e8897f737fb507ac5f409cad
-workspace.zh.md: 309bff841c36b7cd344c49a0c4e750c40d38a43e
+workspace.md: 2c557c435c9eae9257e41ed2362fed832e437422
+workspace.zh.md: 76630506a2db6347e7155aedb12304a1c434959d

+ 19 - 0
docs/subsystems/workspace.md

@@ -232,6 +232,13 @@ Host service backing the generated `ctx.remote.workspace` namespace.
  */
 @Remote('archiveSession') archiveSession(request: WorkspaceArchiveSessionRequest): Promise<WorkspaceArchiveValue>
 
+/**
+ * Restore one archived Session to Workspace grouping surfaces.
+ * @param request - Session identity to unarchive.
+ * @returns the complete resulting archive set.
+ */
+@Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue>
+
 /**
  * Stream a complete Workspace baseline followed by ordered increments.
  * @param signal - generation cancellation.
@@ -384,6 +391,18 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
  */
 archiveSession(sessionId: SessionId): Promise<void>
 
+/**
+ * Unarchive one session durably by dropping it from the registry-global
+ * archive set; the accounting slot was never touched, so the session
+ * returns to its recorded position. Unarchiving runs no session-existence
+ * check because removing an id cannot introduce an unknown one, so an
+ * entry whose session is gone still resolves. An id that is not archived
+ * resolves without writing.
+ * @param sessionId - The session to unarchive.
+ * @returns resolution after durability.
+ */
+unarchiveSession(sessionId: SessionId): Promise<void>
+
 /**
  * Resolve by canonical directory path without creating or mutating a
  * workspace. A missing path rejects during `realpath`; an existing unowned

+ 19 - 0
docs/subsystems/workspace.zh.md

@@ -232,6 +232,13 @@ Host service backing the generated `ctx.remote.workspace` namespace.
  */
 @Remote('archiveSession') archiveSession(request: WorkspaceArchiveSessionRequest): Promise<WorkspaceArchiveValue>
 
+/**
+ * Restore one archived Session to Workspace grouping surfaces.
+ * @param request - Session identity to unarchive.
+ * @returns the complete resulting archive set.
+ */
+@Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue>
+
 /**
  * Stream a complete Workspace baseline followed by ordered increments.
  * @param signal - generation cancellation.
@@ -384,6 +391,18 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
  */
 archiveSession(sessionId: SessionId): Promise<void>
 
+/**
+ * Unarchive one session durably by dropping it from the registry-global
+ * archive set; the accounting slot was never touched, so the session
+ * returns to its recorded position. Unarchiving runs no session-existence
+ * check because removing an id cannot introduce an unknown one, so an
+ * entry whose session is gone still resolves. An id that is not archived
+ * resolves without writing.
+ * @param sessionId - The session to unarchive.
+ * @returns resolution after durability.
+ */
+unarchiveSession(sessionId: SessionId): Promise<void>
+
 /**
  * Resolve by canonical directory path without creating or mutating a
  * workspace. A missing path rejects during `realpath`; an existing unowned

+ 8 - 0
packages/api/session-controller/tests/fake-api.client.ts

@@ -194,6 +194,9 @@ export class FakeApiClient {
   onWorkspaceArchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
     payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
 
+  onWorkspaceUnarchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
+    () => Promise.resolve(ok({ archivedSessionIds: [] }))
+
   /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */
   sessionRemotes(): RuntimeRemotes {
     return {
@@ -272,6 +275,11 @@ export class FakeApiClient {
           payload,
           this.onWorkspaceArchiveSession(payload),
         ),
+        unarchiveSession: payload => this.record(
+          'workspace.unarchiveSession',
+          payload,
+          this.onWorkspaceUnarchiveSession(payload),
+        ),
         follow: signal => this.openWorkspace(signal),
       },
     }

+ 2 - 2
packages/api/workspace-controller/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/api/workspace-controller/README.md
-README.md: d4bff38e8bc412dcdc81acab6bd3febc3a9be448
-README.zh.md: 6a783af428cadb9aeb11bec3d1a50f26eb6c77c1
+README.md: ccf07bfb61ad005d516928ad27145148a62ddf19
+README.zh.md: b96f49e92696cef2475458f4b51c764063aa32f9

+ 1 - 1
packages/api/workspace-controller/README.md

@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive Sessions from Workspace navigation, and follow the complete Workspace projection. Use it through API Gateway when a Client must change or follow Workspace navigation. The package also owns `ctx.directoryPickerController` and the generated `ctx.remote.directoryPicker` namespace, because the directory-picking seam it carries is abstract and never a Loader entry of its own.
+`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive and unarchive Sessions from Workspace navigation, and follow the complete Workspace projection. Use it through API Gateway when a Client must change or follow Workspace navigation. The package also owns `ctx.directoryPickerController` and the generated `ctx.remote.directoryPicker` namespace, because the directory-picking seam it carries is abstract and never a Loader entry of its own.
 
 ## Table of Contents
 

+ 1 - 1
packages/api/workspace-controller/README.zh.md

@@ -8,7 +8,7 @@ kind: "package-reference"
 
 ## 概述
 
-`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,从 Workspace 导航中归档 Session,以及跟随完整的 Workspace 投影。当 Client 必须修改或跟随 Workspace 导航时,请通过 API 网关使用它。本包同时拥有 `ctx.directoryPickerController` 与生成的 `ctx.remote.directoryPicker` namespace,因为它承载的选目录 seam 是抽象的,自身从不作为 Loader entry。
+`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,归档与取消归档 Session,以及跟随完整的 Workspace 投影。当 Client 必须修改或跟随 Workspace 导航时,请通过 API 网关使用它。本包同时拥有 `ctx.directoryPickerController` 与生成的 `ctx.remote.directoryPicker` namespace,因为它承载的选目录 seam 是抽象的,自身从不作为 Loader entry。
 
 ## 目录
 

+ 14 - 0
packages/api/workspace-controller/src/client/model.ts

@@ -13,6 +13,7 @@ import type {
   WorkspaceDeleteValue,
   WorkspaceInsertSessionBeforeRequest,
   WorkspaceOrderValue,
+  WorkspaceUnarchiveSessionRequest,
   WorkspaceValue,
   WorkspaceId,
   WorkspaceView,
@@ -170,6 +171,19 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
     return result
   }
 
+  /**
+   * Unarchive one Session and install the returned complete archive set.
+   * @param sessionId - Session to unarchive.
+   * @returns generated Remote result.
+   */
+  async unarchiveSession(
+    sessionId: WorkspaceUnarchiveSessionRequest['sessionId'],
+  ): Promise<RemoteResult<WorkspaceArchiveValue>> {
+    const result = await this.remote.unarchiveSession({ sessionId })
+    if (result.ok) this.installArchived(result.value.archivedSessionIds)
+    return result
+  }
+
   /**
    * Replace the projection from one complete stream-generation baseline.
    * @param baseline - complete Workspace and archive projection.

+ 10 - 0
packages/api/workspace-controller/src/client/service.ts

@@ -62,6 +62,11 @@ export interface IWorkspaces {
    * @param sessionId - Session to archive.
    */
   archiveSession(sessionId: SessionId): Promise<void>
+  /**
+   * Unarchive a Session from the archived Session list.
+   * @param sessionId - Session to unarchive.
+   */
+  unarchiveSession(sessionId: SessionId): Promise<void>
   /**
    * Move a Session within one Workspace account.
    * @param workspaceId - owning Workspace.
@@ -116,6 +121,11 @@ export class WorkspaceController extends Service implements IWorkspaces {
     if (!result.ok) throw commandError('session archive', result.error)
   }
 
+  async unarchiveSession(sessionId: SessionId): Promise<void> {
+    const result = await this.model.unarchiveSession(sessionId)
+    if (!result.ok) throw commandError('session unarchive', result.error)
+  }
+
   async insertSessionBefore(
     workspaceId: WorkspaceId,
     sessionId: SessionId,

+ 13 - 0
packages/api/workspace-controller/src/commands.ts

@@ -21,6 +21,7 @@ import type {
   WorkspaceInsertSessionBeforeRequest,
   WorkspaceOrderValue,
   WorkspaceRenameRequest,
+  WorkspaceUnarchiveSessionRequest,
   WorkspaceValue,
 } from './types.ts'
 
@@ -160,6 +161,18 @@ export class WorkspaceCommands {
     return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
   }
 
+  /**
+   * Drop one Session from the registry-global archive set. An id that is not
+   * archived is not an error: the call is idempotent, so a lost race with
+   * another surface resolves as a no-op.
+   * @param request - Session identity to unarchive.
+   * @returns the complete resulting archive set.
+   */
+  async unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue> {
+    await this.ctx.workspaceRegistry.unarchiveSession(request.sessionId)
+    return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
+  }
+
   private requireWorkspace(workspaceId: WorkspaceId): Workspace {
     const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId))
     if (workspace === undefined) throw workspaceNotFound(workspaceId)

+ 11 - 0
packages/api/workspace-controller/src/index.ts

@@ -17,6 +17,7 @@ import type {
   WorkspaceInsertSessionBeforeRequest,
   WorkspaceOrderValue,
   WorkspaceRenameRequest,
+  WorkspaceUnarchiveSessionRequest,
   WorkspaceValue,
 } from './types.ts'
 
@@ -109,6 +110,16 @@ export class WorkspaceController extends TypertRemoteService {
     return this.commands.archiveSession(request)
   }
 
+  /**
+   * Restore one archived Session to Workspace grouping surfaces.
+   * @param request - Session identity to unarchive.
+   * @returns the complete resulting archive set.
+   */
+  @Remote('unarchiveSession')
+  unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue> {
+    return this.commands.unarchiveSession(request)
+  }
+
   /**
    * Stream a complete Workspace baseline followed by ordered increments.
    * @param signal - generation cancellation.

+ 5 - 0
packages/api/workspace-controller/src/types.ts

@@ -104,6 +104,11 @@ export interface WorkspaceArchiveSessionRequest {
   readonly sessionId: SessionId
 }
 
+/** Session requested for restoration from the archived Session list. */
+export interface WorkspaceUnarchiveSessionRequest {
+  readonly sessionId: SessionId
+}
+
 /** Complete archived Session set after a mutation. */
 export interface WorkspaceArchiveValue {
   readonly archivedSessionIds: readonly SessionId[]

+ 20 - 0
packages/api/workspace-controller/tests/model.client.spec.ts

@@ -14,6 +14,7 @@ import type {
   WorkspaceInsertSessionBeforeRequest,
   WorkspaceOrderValue,
   WorkspaceRenameRequest,
+  WorkspaceUnarchiveSessionRequest,
   WorkspaceValue,
   WorkspaceId,
   WorkspaceView,
@@ -84,6 +85,10 @@ class FakeWorkspaceRemote implements WorkspaceRemote {
     request: WorkspaceArchiveSessionRequest,
   ) => Promise<RemoteResult<WorkspaceArchiveValue>> = request =>
     Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
+  onUnarchiveSession: (
+    request: WorkspaceUnarchiveSessionRequest,
+  ) => Promise<RemoteResult<WorkspaceArchiveValue>> = request =>
+    Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
 
   create(request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
     this.record('create', request)
@@ -115,6 +120,11 @@ class FakeWorkspaceRemote implements WorkspaceRemote {
     return this.onArchiveSession(request)
   }
 
+  unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<RemoteResult<WorkspaceArchiveValue>> {
+    this.record('unarchiveSession', request)
+    return this.onUnarchiveSession(request)
+  }
+
   async *follow(_signal?: AbortSignal): AsyncGenerator<WorkspaceFollowFrame> {}
 
   private record(method: string, request: unknown): void {
@@ -306,6 +316,16 @@ describe('ClientWorkspaceModel', () => {
     remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
     await expect(model.archiveSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
     expect(model.getSnapshot().archivedSessionIds).toEqual(['fresh'])
+
+    remote.onUnarchiveSession = () => Promise.resolve(workspaceError(
+      new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }),
+    ))
+    await expect(model.unarchiveSession(sid('missing'))).resolves.toMatchObject({ ok: false })
+    expect(model.getSnapshot().archivedSessionIds).toEqual(['fresh'])
+    remote.onUnarchiveSession = () => Promise.resolve(remoteOk({ archivedSessionIds: [] }))
+    await expect(model.unarchiveSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
+    expect(model.getSnapshot().archivedSessionIds).toEqual([])
+    expect(remote.calls).toContainEqual({ method: 'unarchiveSession', request: { sessionId: 'fresh' } })
   })
 
   it('keeps the newest row and places Workspaces missing from partial orders last', async () => {

+ 2 - 0
packages/api/workspace-controller/tests/remote/workspace.client.ts

@@ -20,6 +20,7 @@ import type {
   WorkspaceInsertSessionBeforeRequest,
   WorkspaceOrderValue,
   WorkspaceRenameRequest,
+  WorkspaceUnarchiveSessionRequest,
   WorkspaceValue,
   WorkspaceView,
 } from '../../src/types.ts'
@@ -96,5 +97,6 @@ export const workspaceWorld: RemoteTable = {
       workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }),
     }),
     'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [request.sessionId] }),
+    'workspace/unarchiveSession': (_request: WorkspaceUnarchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [] }),
   },
 }

+ 5 - 0
packages/api/workspace-controller/tests/transport.client.spec.ts

@@ -321,6 +321,7 @@ describe('WorkspaceController', () => {
       sessionIds: ['session'],
     })
     await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined()
+    await expect(controller.unarchiveSession(sid('session'))).resolves.toBeUndefined()
     await expect(controller.delete(wid('one'))).resolves.toBeUndefined()
     // Each command crosses the wire as one positional request object.
     expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }])
@@ -328,6 +329,7 @@ describe('WorkspaceController', () => {
     expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }])
     expect(mock.log.requests('workspace/insertSessionBefore')).toEqual([{ workspaceId: 'one', sessionId: 'session' }])
     expect(mock.log.requests('workspace/archiveSession')).toEqual([{ sessionId: 'session' }])
+    expect(mock.log.requests('workspace/unarchiveSession')).toEqual([{ sessionId: 'session' }])
     expect(mock.log.requests('workspace/delete')).toEqual([{ workspaceId: 'one' }])
   })
 
@@ -351,6 +353,9 @@ describe('WorkspaceController', () => {
     mock.remote.workspace.archiveSession.mockResolvedValueOnce(err(missingSession))
     await expect(controller.archiveSession(sid('session')))
       .rejects.toThrow('workspace session archive failed: session/not-found: missing session')
+    mock.remote.workspace.unarchiveSession.mockResolvedValueOnce(err(missingSession))
+    await expect(controller.unarchiveSession(sid('session')))
+      .rejects.toThrow('workspace session unarchive failed: session/not-found: missing session')
     mock.remote.workspace.insertSessionBefore.mockResolvedValueOnce(err(new RemoteError(
       'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') },
     )))

+ 15 - 0
packages/api/workspace-controller/tests/workspace-controller.host.spec.ts

@@ -151,6 +151,11 @@ describe('WorkspaceController commands', () => {
     vi.spyOn(ctx.workspaceRegistry, 'archiveSession').mockRejectedValueOnce(archiveFailure)
     await expect(controller.archiveSession({ sessionId: SessionId('session') }))
       .rejects.toBe(archiveFailure)
+
+    const unarchiveFailure = new Error('unarchive storage failed')
+    vi.spyOn(ctx.workspaceRegistry, 'unarchiveSession').mockRejectedValueOnce(unarchiveFailure)
+    await expect(controller.unarchiveSession({ sessionId: SessionId('session') }))
+      .rejects.toBe(unarchiveFailure)
   })
 
   it('resolves queued Workspace identities when their operation starts', async () => {
@@ -221,6 +226,11 @@ describe('WorkspaceController commands', () => {
       .resolves.toEqual({ archivedSessionIds: [session.id] })
     await expect(controller.archiveSession({ sessionId: SessionId('unknown') }))
       .rejects.toMatchObject({ code: 'session/not-found' })
+    await expect(controller.unarchiveSession({ sessionId: session.id }))
+      .resolves.toEqual({ archivedSessionIds: [] })
+    // Unarchive is idempotent: an id that is not archived is not an error.
+    await expect(controller.unarchiveSession({ sessionId: session.id }))
+      .resolves.toEqual({ archivedSessionIds: [] })
   })
 })
 
@@ -292,6 +302,11 @@ describe('WorkspaceController follow', () => {
     await expect(nextFrame(iterator)).resolves.toEqual({
       type: 'archived', archivedSessionIds: [session.id],
     })
+    // Unarchive rides the same complete-set increment: no new frame type.
+    await controller.unarchiveSession({ sessionId: session.id })
+    await expect(nextFrame(iterator)).resolves.toEqual({
+      type: 'archived', archivedSessionIds: [],
+    })
     await controller.delete({ workspaceId: second.workspace.workspaceId })
     await expect(nextFrame(iterator)).resolves.toEqual({
       type: 'order', workspaceIds: [first.workspace.workspaceId],

+ 3 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -243,6 +243,9 @@
     - id: ui-settings-plugin-inventory
       name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory'
 
+    - id: ui-settings-unarchive-sessions
+      name: '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions'
+
     - id: ui-conversation
       name: '@deepseek-ai/dsh-client-ui-conversation'
 

+ 1 - 0
packages/bundle/web-app/package.json

@@ -81,6 +81,7 @@
     "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",

+ 12 - 0
packages/client/connection/src/client/fixture.ts

@@ -358,6 +358,7 @@ interface WorkspaceInsertSessionBeforeRequest {
   readonly beforeSessionId?: SessionId
 }
 interface WorkspaceArchiveSessionRequest { readonly sessionId: SessionId }
+interface WorkspaceUnarchiveSessionRequest { readonly sessionId: SessionId }
 interface WorkspaceArchiveValue { readonly archivedSessionIds: readonly SessionId[] }
 
 type WorkspaceFollowFrame =
@@ -380,6 +381,7 @@ interface FixtureWorkspaceApi {
   insertBefore(request: WorkspaceInsertBeforeRequest): Promise<ConnectionRpcResult<WorkspaceOrderValue>>
   insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise<ConnectionRpcResult<WorkspaceValue>>
   archiveSession(request: WorkspaceArchiveSessionRequest): Promise<ConnectionRpcResult<WorkspaceArchiveValue>>
+  unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<ConnectionRpcResult<WorkspaceArchiveValue>>
 }
 
 interface FixtureWorkspace {
@@ -3802,6 +3804,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       }
       return sessionOk({ archivedSessionIds: [...archivedSessionIds] })
     },
+    unarchiveSession: (request) => {
+      if (archivedSessionIds.includes(request.sessionId)) {
+        archivedSessionIds.splice(archivedSessionIds.indexOf(request.sessionId), 1)
+        emitWorkspace({ type: 'archived', archivedSessionIds: [...archivedSessionIds] })
+      }
+      return sessionOk({ archivedSessionIds: [...archivedSessionIds] })
+    },
   }
 
   const rpc: ClientConnectionRpc = {
@@ -3995,6 +4004,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
           request as WorkspaceInsertSessionBeforeRequest,
         )
         case 'workspace/archiveSession': return workspaceApi.archiveSession(request as WorkspaceArchiveSessionRequest)
+        case 'workspace/unarchiveSession': return workspaceApi.unarchiveSession(
+          request as WorkspaceUnarchiveSessionRequest,
+        )
         default:
           return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`))
       }

+ 14 - 0
packages/client/connection/tests/fixture.client.spec.ts

@@ -194,6 +194,7 @@ interface FixtureWorkspaceRequests {
     readonly beforeSessionId?: SessionId
   }
   archiveSession: { readonly sessionId: SessionId }
+  unarchiveSession: { readonly sessionId: SessionId }
 }
 
 interface FixtureWorkspaceValues {
@@ -203,6 +204,7 @@ interface FixtureWorkspaceValues {
   insertBefore: { readonly workspaceIds: readonly WorkspaceId[] }
   insertSessionBefore: { readonly workspace: FixtureWorkspaceView }
   archiveSession: { readonly archivedSessionIds: readonly SessionId[] }
+  unarchiveSession: { readonly archivedSessionIds: readonly SessionId[] }
 }
 
 type FixtureWorkspaceApi = {
@@ -471,6 +473,7 @@ function createWorkspaceApi(rpc: ClientConnectionRpc): FixtureWorkspaceApi {
     insertBefore: (request, signal) => call('insertBefore', request, signal),
     insertSessionBefore: (request, signal) => call('insertSessionBefore', request, signal),
     archiveSession: (request, signal) => call('archiveSession', request, signal),
+    unarchiveSession: (request, signal) => call('unarchiveSession', request, signal),
   }
 }
 
@@ -483,6 +486,7 @@ function createWorkspaceClient(rpc: ClientConnectionRpc): FixtureWorkspaceClient
     insertBefore: (request, signal) => api.insertBefore(req(request), signal),
     insertSessionBefore: (request, signal) => api.insertSessionBefore(req(request), signal),
     archiveSession: (request, signal) => api.archiveSession(req(request), signal),
+    unarchiveSession: (request, signal) => api.unarchiveSession(req(request), signal),
   }
 }
 
@@ -1691,6 +1695,16 @@ describe('fixture Connection RPC', () => {
     const moved = await workspaces.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
     if (!moved.result.ok) throw new Error('workspace move failed')
     expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
+    const archived = await workspaces.archiveSession({ sessionId: attached.result.value.sessionId })
+    if (!archived.result.ok) throw new Error('workspace archive failed')
+    expect(archived.result.value.archivedSessionIds).toEqual([attached.result.value.sessionId])
+    const unarchived = await workspaces.unarchiveSession({ sessionId: attached.result.value.sessionId })
+    if (!unarchived.result.ok) throw new Error('workspace unarchive failed')
+    expect(unarchived.result.value.archivedSessionIds).toEqual([])
+    // A second unarchive of the same id leaves the set unchanged.
+    const repeated = await workspaces.unarchiveSession({ sessionId: attached.result.value.sessionId })
+    if (!repeated.result.ok) throw new Error('repeated workspace unarchive failed')
+    expect(repeated.result.value.archivedSessionIds).toEqual([])
   })
 
   it('folds the goal lifecycle over the Goal Remotes', async () => {

+ 3 - 1
packages/client/ui-settings-general/src/client/SettingsRoot.tsx

@@ -14,7 +14,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from
 import clsx from 'clsx'
 import {
   ConnectionIndicator,
-  IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16,
+  IconAgentPresetOutline16, IconArchiveOutline20, IconCloseOutline16, IconDataOutline16,
   IconPersonalizationOutline16, IconSettingsOutline16,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ConnectionIndicatorState } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -31,6 +31,8 @@ function navIcon(id: string) {
   if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
   if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} />
   if (id === 'plugins') return <IconPersonalizationOutline16 className={css.navIcon} size={16} />
+  // 20-native glyph in the rail's 16px icon slot, as on the Session row menu.
+  if (id === 'archived-sessions') return <IconArchiveOutline20 className={css.navIcon} size={16} />
   return <IconSettingsOutline16 className={css.navIcon} size={16} />
 }
 

+ 6 - 5
packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

@@ -305,19 +305,20 @@ describe('SettingsPanel navigation', () => {
         { id: 'models', order: 10, label: 'Models' },
         { id: 'agent-presets', order: 20, label: 'Agent presets' },
         { id: 'plugins', order: 30, label: 'Plugins' },
-        { id: 'contributed', order: 40, label: 'Contributed' },
+        { id: 'archived-sessions', order: 40, label: 'Archived sessions' },
+        { id: 'contributed', order: 50, label: 'Contributed' },
       ],
     })
     openPanel()
     // Glyphs carry no id of their own, so the drawn paths are what tells them apart.
-    const glyphs = ['General', 'Models', 'Agent presets', 'Plugins', 'Contributed']
+    const glyphs = ['General', 'Models', 'Agent presets', 'Plugins', 'Archived sessions', 'Contributed']
       .map(name => screen.getByRole('button', { name }).querySelector('svg')?.innerHTML)
 
     expect(glyphs.every(glyph => glyph !== undefined && glyph !== '')).toBe(true)
-    // The three ids the shell names get their own glyph; every other section —
+    // The four ids the shell names get their own glyph; every other section —
     // including one this package never heard of — shares the gear.
-    expect(new Set(glyphs.slice(0, 4)).size).toBe(4)
-    expect(glyphs[4]).toBe(glyphs[0])
+    expect(new Set(glyphs.slice(0, 5)).size).toBe(5)
+    expect(glyphs[5]).toBe(glyphs[0])
   })
 
   it('switches the rendered section on nav click', () => {

+ 3 - 3
packages/client/ui-settings-general/tests/shell.client.spec.ts

@@ -36,10 +36,10 @@ const CHILD_NAMES = Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>
 
 /**
  * Section ids the web-app roster registers, in nav order: this package, then
- * ui-settings-models, ui-settings-plugins, and ui-agent-preset. A plugin adding
- * a section changes this list.
+ * ui-settings-models, ui-settings-plugins, ui-agent-preset, and
+ * ui-settings-unarchive-sessions. A plugin adding a section changes this list.
  */
-const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets']
+const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets', 'archived-sessions']
 /** Onboarding steps the web-app roster registers, in coordinator order; both come from ui-settings-models. */
 const PRODUCT_ONBOARDING: readonly { id: string; order: number }[] = [
   { id: 'welcome-notice', order: -100 },

+ 6 - 0
packages/client/ui-settings-unarchive-sessions/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-settings-unarchive-sessions/README.md
+README.md: 16c08d87ddd4aae1f2f81f606acb321443e78a0d
+README.zh.md: 39944e30e2e85ff75e7101256fda675d7e337974

+ 112 - 0
packages/client/ui-settings-unarchive-sessions/README.md

@@ -0,0 +1,112 @@
+---
+description: "Archived-session Settings page for the dsh web client: the registry-global archive set as a searchable list with one Unarchive action per row."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-settings-unarchive-sessions
+
+English | [中文](README.zh.md)
+
+## Summary
+
+The **Archived sessions** Settings page is the restore point for sessions hidden from Workspace navigation. It lists each archived session with the Workspace that owns it and its last activity, newest archive first, and offers one Unarchive action per row. A search box filters the list by session title or Workspace name. Rows come from the archive set joined with the loaded Session summaries, so an archive entry whose session record is gone has no row and no action. Every restore goes through the shared Workspace command.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Open Settings and select **Archived sessions** to see the sessions currently hidden from every grouping surface. Mount `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` in a Web composition that already provides the settings shell, the Workspace service, and the Session list; the page registers its own navigation entry and needs no configuration.
+
+### Reading a row
+
+Each row shows the session's display title, the title of the Workspace that accounts for it or the ungrouped label, and its last activity as a compact relative time. The page lists the most recently archived session first, the reverse of the durable archive order. The page waits for the Session list before rendering rows; while that list loads it shows a reading status instead of an empty archive. An empty archive and a query matching no row report different messages, so a search never looks like a missing archive.
+
+### Restoring a session
+
+Unarchive restores the session to its recorded position under its Workspace, or to the ungrouped sessions when it belongs to none, and the row disappears from the page. The action calls `ctx.uiWorkspace.unarchiveSession`, whose echoed archive set updates every surface that filters on it, so the session reappears in the sidebar and search as well. A rejected call is logged as a console diagnostic and leaves the row in place for another attempt.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The page is one localized `settings.section` contribution with id `archived-sessions`; the Settings shell owns the navigation entry, the modal, and the mounted section, so none of that chrome lives here.
+
+### Registration and data sources
+
+`apply()` registers the locale namespace, binds it, and uses `ctx.slots.inject()` to contribute the section, so a late or restored slot declaration still reaches it. The page reads `state.archivedSessionIds` and `state.items` from `useWorkspaces` and the Session summaries from `useSessions`; it owns no store and no transport, and its only write is the injected `unarchive` callback that the registration closes over `ctx.uiWorkspace.unarchiveSession`.
+
+### Row derivation
+
+Rows are derived from the archive set joined with the loaded summaries: a member with no summary produces no row, which is why a session deleted outside the Workspace registry leaves no unarchive action behind. Workspace ownership is read from each Workspace's `sessionIds`; a member outside every Workspace renders with the ungrouped label. Search normalizes the query once and matches it against the row title and Workspace label.
+
+### Source map
+
+| File | Role |
+|---|---|
+| [`src/index.ts`](src/index.ts) | Host loader entry: the page is browser-only, so the plugin body is empty |
+| [`src/client/index.ts`](src/client/index.ts) | Browser plugin: locale namespace, section registration, injected Unarchive operation |
+| [`src/client/ArchivedSessionsSection.tsx`](src/client/ArchivedSessionsSection.tsx) | The page component: row derivation, search, per-row Unarchive |
+| [`src/client/locales.ts`](src/client/locales.ts) | Chinese and English dictionaries for every visible and accessible string |
+| [`src/client/ArchivedSessionsSection.module.css`](src/client/ArchivedSessionsSection.module.css) | Page styles |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+These pages cover the settings surface that hosts the page, the archive write behind it, and the state it renders.
+
+- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.section` and the namespace scope service.
+- [ui-settings-general](../ui-settings-general/README.md) — the Settings shell that renders the navigation and mounts the section.
+- [ui-workspace](../ui-workspace/README.md) — the sidebar browser whose Session rows archive, and the `ctx.uiWorkspace` service this page restores through.
+- [Workspace Controller](../../api/workspace-controller/README.md) — the `workspace.unarchiveSession` Remote and the Client model that owns the archive set.
+- [Workspace subsystem](../../../docs/subsystems/workspace.md) — the durable archive set, its domain field, and the registry operation behind a restore.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the package is a browser-side UI plugin layer that registers nothing model-facing.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+These limits define which archived sessions this page can restore; they are current package constraints.
+
+- **Archived sessions without a loaded summary are unaddressable** — the page derives its rows by joining the archive set with the Session list, so a member the list does not carry has no row and no Unarchive action even though the archive set still holds it.
+- **The page lists sessions only; it offers no session deletion** — archives are reversible through this page, while deleting a session record remains a separate capability.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>
+
+**Runtime invariant:** No companion is published. A browser-side settings page that registers one localized `settings.section` contribution and its locale namespace; it emits no Cordis events and owns no cross-plugin mutable relation.

+ 112 - 0
packages/client/ui-settings-unarchive-sessions/README.zh.md

@@ -0,0 +1,112 @@
+---
+description: "dsh Web 客户端的已归档会话设置页:把注册表全局归档集合呈现为可搜索列表,每行提供一个取消归档操作。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-settings-unarchive-sessions
+
+[English](README.md) | 中文
+
+## 概述
+
+**已归档会话**设置页是从 Workspace 导航中隐藏的会话的恢复入口。它按归档时间由新到旧列出每个已归档会话,并显示其所属 Workspace 与最近活动时间,每行提供一个取消归档操作。搜索框按会话标题或 Workspace 名称过滤列表。行由归档集合与已加载的 Session 摘要合并而来,因此会话记录已不存在的归档条目既没有行也没有操作。每次恢复都经由共享的 Workspace 命令。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+打开设置并选择**已归档会话**,即可看到当前从所有分组视图中隐藏的会话。在已提供设置外壳、Workspace 服务与 Session 列表的 Web 组合中挂载 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`;该页面注册自己的导航条目,无需配置。
+
+### 阅读一行
+
+每行显示会话的显示标题、为其记账的 Workspace 标题或未分组标签,以及以紧凑相对时间表示的最近活动。页面把最近归档的会话排在前面,与持久归档顺序相反。页面会先等待 Session 列表再渲染行;列表加载期间显示读取状态,而不是空归档。空归档与查询无匹配给出不同的提示,因此搜索不会被误认为归档为空。
+
+### 恢复会话
+
+取消归档会把会话恢复到其 Workspace 下记录的位置;会话不属于任何 Workspace 时则恢复到未分组会话中,该行随即从页面消失。该操作调用 `ctx.uiWorkspace.unarchiveSession`,其回传的完整归档集合会更新所有依据它过滤的界面,因此会话也会重新出现在侧边栏与搜索中。调用被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+页面是 id 为 `archived-sessions` 的一个本地化 `settings.section` 贡献;导航条目、模态框与挂载的分区都归设置外壳所有,不在此包内。
+
+### 注册与数据来源
+
+`apply()` 注册并绑定 locale namespace,再用 `ctx.slots.inject()` 贡献该分区,因此延迟声明或恢复声明的 slot 依然能触达它。页面从 `useWorkspaces` 读取 `state.archivedSessionIds` 与 `state.items`,从 `useSessions` 读取 Session 摘要;它不持有 store 也不持有 transport,唯一的写入是注册处以 `ctx.uiWorkspace.unarchiveSession` 闭包注入的 `unarchive` 回调。
+
+### 行的派生
+
+行由归档集合与已加载摘要合并派生:没有摘要的成员不产生行,因此在 Workspace 注册表之外被删除的会话不会留下取消归档操作。Workspace 归属从各 Workspace 的 `sessionIds` 读取;不属于任何 Workspace 的成员以未分组标签渲染。搜索只规范化一次查询,并与行标题和 Workspace 标签匹配。
+
+### 源码地图
+
+| 文件 | 职责 |
+|---|---|
+| [`src/index.ts`](src/index.ts) | 宿主 loader 入口:该页面仅供浏览器使用,因此插件体为空 |
+| [`src/client/index.ts`](src/client/index.ts) | 浏览器插件:locale namespace、分区注册、注入的取消归档操作 |
+| [`src/client/ArchivedSessionsSection.tsx`](src/client/ArchivedSessionsSection.tsx) | 页面组件:行的派生、搜索、每行的取消归档 |
+| [`src/client/locales.ts`](src/client/locales.ts) | 全部可见与无障碍字符串的中英文字典 |
+| [`src/client/ArchivedSessionsSection.module.css`](src/client/ArchivedSessionsSection.module.css) | 页面样式 |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+以下页面覆盖承载该页面的设置界面、其背后的归档写入,以及它所渲染的状态。
+
+- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.section` 与 namespace scope 服务的领域底座。
+- [ui-settings-general](../ui-settings-general/README.zh.md)——渲染导航并挂载该分区的设置外壳。
+- [ui-workspace](../ui-workspace/README.zh.md)——其 Session 行负责归档的侧边栏浏览器,以及本页面借以恢复的 `ctx.uiWorkspace` 服务。
+- [Workspace Controller](../../api/workspace-controller/README.zh.md)——`workspace.unarchiveSession` Remote 与持有归档集合的 Client model。
+- [Workspace 子系统](../../../docs/subsystems/workspace.zh.md)——持久归档集合、其领域字段,以及恢复背后的注册表操作。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无。该包是浏览器端 UI 插件层,不注册任何面向模型的内容。
+
+#### KV Cache 影响
+
+无;该包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+这些限制界定本页面能够恢复哪些已归档会话;它们是当前包约束。
+
+- **已加载摘要缺失的已归档会话无法寻址**:页面通过与 Session 列表合并来派生行,因此列表未携带的成员没有行也没有取消归档操作,尽管归档集合仍持有它。
+- **页面只列出会话,不提供会话删除**:归档可通过本页面恢复,而删除会话记录仍是彼此独立的能力。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>
+
+**运行时不变式:** 不发布伴生入口。本包是浏览器端设置页,只注册一个本地化 `settings.section` 贡献及其 locale namespace;它不发出 Cordis 事件,也不持有跨插件可变关系。

+ 73 - 0
packages/client/ui-settings-unarchive-sessions/package.json

@@ -0,0 +1,73 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions",
+  "description": "Archived-session settings page: the registry-global archive set with one Unarchive action per row",
+  "version": "0.1.5-rc.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-settings-unarchive-sessions"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-api-session-controller",
+        "@deepseek-ai/dsh-api-workspace-controller",
+        "@deepseek-ai/dsh-client-locale",
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-session",
+        "@deepseek-ai/dsh-client-ui-settings",
+        "@deepseek-ai/dsh-client-ui-workspace"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@testing-library/react": "^16.1.0",
+    "@types/react": "~18.3.1",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 92 - 0
packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css

@@ -0,0 +1,92 @@
+.section {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  width: 100%;
+  max-width: 760px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.status {
+  margin: 0;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.search {
+  position: relative;
+  display: flex;
+  align-items: center;
+  width: 100%;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.search > svg {
+  position: absolute;
+  left: 12px;
+  pointer-events: none;
+}
+
+.search input {
+  width: 100%;
+  height: 32px;
+  padding: 0 12px 0 36px;
+  border: 0.5px solid var(--dsw-alias-border-l2);
+  border-radius: 8px;
+  background: var(--dsw-alias-bg-base);
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+}
+
+.visuallyHidden {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+}
+
+.list {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 8px 10px;
+  border-radius: 8px;
+}
+
+.row:hover {
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.identity {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+}
+
+.title {
+  overflow: hidden;
+  font-size: 13px;
+  line-height: 20px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.meta {
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+}

+ 130 - 0
packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx

@@ -0,0 +1,130 @@
+/**
+ * Archived-session Settings page: the registry-global archive set joined with
+ * the loaded Session summaries, newest archive first, filtered by one search
+ * box, with one Unarchive action per row. An archive entry whose Session is
+ * gone has no row and no action; the set itself stays host-owned.
+ */
+import { useState, type ReactNode } from 'react'
+import { Button, IconSearchOutline16, relativeTime } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import css from './ArchivedSessionsSection.module.css'
+
+/** Registration-side face used by the page. */
+export interface ArchivedSessionsSectionInjected {
+  /**
+   * Restore one archived Session.
+   * @param sessionId - Session to unarchive.
+   */
+  unarchive: (sessionId: SessionId) => Promise<void>
+}
+
+/** Full component props assembled by the Settings slot renderer. */
+export type ArchivedSessionsSectionProps =
+  PropsRuntime<'settings.section'>
+  & PropsLocale<'settings.archivedSessions'>
+  & InjectFace<ArchivedSessionsSectionInjected>
+
+type Translate = ArchivedSessionsSectionProps['t']
+
+/** One archived Session as the page renders it. */
+interface ArchivedRow {
+  id: SessionId
+  title: string
+  /** Owning Workspace title, or the ungrouped label for a Session outside every Workspace. */
+  workspace: string
+  updatedAt: number
+}
+
+/** Localized compact relative time of one row's last activity. */
+function timeLabel(updatedAt: number, now: number, t: Translate): string {
+  const { unit, n } = relativeTime(updatedAt, now)
+  return unit === 'now' ? t('time.now') : t(`time.${unit}`, { n })
+}
+
+/** Whether one row matches the normalized query in its title or Workspace label. */
+function matches(row: ArchivedRow, normalizedQuery: string): boolean {
+  return normalizedQuery.length === 0
+    || row.title.toLocaleLowerCase().includes(normalizedQuery)
+    || row.workspace.toLocaleLowerCase().includes(normalizedQuery)
+}
+
+/**
+ * Render the archived-session page.
+ * @param props - composed slot props (see {@link ArchivedSessionsSectionProps}).
+ * @returns the settings page element tree.
+ */
+export function ArchivedSessionsSection(props: ArchivedSessionsSectionProps): ReactNode {
+  const { t, unarchive, useSessions, useWorkspaces } = props
+  const sessions = useSessions(state => state)
+  const workspaces = useWorkspaces(state => state.items)
+  const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
+  const [query, setQuery] = useState('')
+
+  const owners = new Map<string, string>()
+  for (const workspace of workspaces) {
+    for (const id of workspace.sessionIds) owners.set(id, workspace.title)
+  }
+  const ungrouped = t('ungrouped')
+  // Archive order is oldest first; the page lists the most recently archived
+  // Session first. A member with no loaded summary is not addressable here.
+  const rows: ArchivedRow[] = [...archivedSessionIds].reverse().flatMap((id) => {
+    const summary = sessions.byId[id]
+    if (summary === undefined) return []
+    return [{
+      id,
+      title: summary.displayTitle,
+      workspace: owners.get(id) ?? ungrouped,
+      updatedAt: summary.updatedAt,
+    }]
+  })
+
+  if (sessions.phase !== 'ready') return <p className={css.status}>{t('loading')}</p>
+
+  const now = Date.now()
+  const visible = rows.filter(row => matches(row, query.trim().toLocaleLowerCase()))
+
+  return (
+    <div className={css.section}>
+      <label className={css.search}>
+        <IconSearchOutline16 aria-hidden="true" />
+        <span className={css.visuallyHidden}>{t('search')}</span>
+        <input
+          type="search"
+          value={query}
+          placeholder={t('search')}
+          aria-label={t('search')}
+          onChange={(event) => { setQuery(event.currentTarget.value) }}
+        />
+      </label>
+      {rows.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
+      {rows.length > 0 && visible.length === 0 ? <p className={css.status}>{t('emptySearch')}</p> : null}
+      {visible.length > 0 ? (
+        <ul className={css.list}>
+          {visible.map(row => (
+            <li key={row.id} className={css.row}>
+              <span className={css.identity}>
+                <span className={css.title}>{row.title}</span>
+                <span className={css.meta}>
+                  {[row.workspace, timeLabel(row.updatedAt, now, t)].join(' · ')}
+                </span>
+              </span>
+              <Button
+                variant="outline"
+                size="sm"
+                aria-label={t('unarchiveNamed', { title: row.title })}
+                onClick={() => {
+                  unarchive(row.id).catch((reason: unknown) => {
+                    console.warn('session unarchive rejected:', reason)
+                  })
+                }}
+              >
+                {t('unarchive')}
+              </Button>
+            </li>
+          ))}
+        </ul>
+      ) : null}
+    </div>
+  )
+}

+ 49 - 0
packages/client/ui-settings-unarchive-sessions/src/client/index.ts

@@ -0,0 +1,49 @@
+/** Archived-session Settings page, browser half. */
+
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
+// Type-only: pulls the `uiWorkspace` Context merge and the `useWorkspaces`
+// global standard prop this page reads.
+import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
+// Type-only: pulls the `useSessions` global standard prop.
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import { ArchivedSessionsSection } from './ArchivedSessionsSection.tsx'
+import type { ArchivedSessionsSectionInjected } from './ArchivedSessionsSection.tsx'
+import { en, zh, type ArchivedSessionsLocaleKey } from './locales.ts'
+
+export type { ArchivedSessionsSectionInjected, ArchivedSessionsSectionProps } from './ArchivedSessionsSection.tsx'
+export type { ArchivedSessionsLocaleKey } from './locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Archived-session page copy. */
+    'settings.archivedSessions': ArchivedSessionsLocaleKey
+  }
+}
+
+/** Dictionary namespace owned by this plugin. */
+export const NS = 'settings.archivedSessions'
+
+/** Services required by the Settings registration and the archive write. */
+export const inject = ['slots', 'locale', 'uiWorkspace']
+
+/** Contribute the archived-session page to Settings. */
+export function apply(ctx: ClientContext): void {
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-unarchive-sessions: dictionaries')
+
+  const t = ctx.locale.bind(NS)
+  const injected = (): ArchivedSessionsSectionInjected => ({
+    unarchive: sessionId => ctx.uiWorkspace.unarchiveSession(sessionId),
+  })
+
+  ctx.slots.inject('settings.section', () => ctx.slots.register({
+    name: 'settings.section',
+    id: 'archived-sessions',
+    order: 25,
+    label: () => t('nav'),
+    locale: NS,
+    inject: injected,
+  }, ArchivedSessionsSection))
+}

+ 40 - 0
packages/client/ui-settings-unarchive-sessions/src/client/locales.ts

@@ -0,0 +1,40 @@
+/** Copy dictionaries for the archived-session Settings page. */
+
+/** Simplified Chinese dictionary and key source of truth. */
+export const zh = {
+  nav: '已归档会话',
+  search: '搜索已归档会话',
+  loading: '正在读取会话…',
+  empty: '暂无已归档会话。',
+  emptySearch: '没有匹配的会话。',
+  unarchive: '取消归档',
+  unarchiveNamed: '取消归档 {title}',
+  ungrouped: '未分组',
+  'time.now': '刚刚',
+  'time.minutes': '{n}分钟',
+  'time.hours': '{n}小时',
+  'time.days': '{n}天',
+  'time.months': '{n}个月',
+  'time.years': '{n}年',
+} satisfies Record<string, string>
+
+/** Archived-session page locale key union. */
+export type ArchivedSessionsLocaleKey = keyof typeof zh
+
+/** English dictionary checked against the Chinese key set. */
+export const en = {
+  nav: 'Archived sessions',
+  search: 'Search archived sessions',
+  loading: 'Reading sessions…',
+  empty: 'No archived sessions.',
+  emptySearch: 'No matching sessions.',
+  unarchive: 'Unarchive',
+  unarchiveNamed: 'Unarchive {title}',
+  ungrouped: 'Ungrouped',
+  'time.now': 'now',
+  'time.minutes': '{n}min',
+  'time.hours': '{n}h',
+  'time.days': '{n}d',
+  'time.months': '{n}mo',
+  'time.years': '{n}y',
+} satisfies Record<ArchivedSessionsLocaleKey, string>

+ 6 - 0
packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 4 - 0
packages/client/ui-settings-unarchive-sessions/src/index.ts

@@ -0,0 +1,4 @@
+/** Host loader entry for the archived-session page exported from `./client`. */
+
+/** Host plugin body — the archived-session page is browser-only. */
+export function apply(): void {}

+ 85 - 0
packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx

@@ -0,0 +1,85 @@
+// @vitest-environment jsdom
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup } from '@testing-library/react'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
+import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { apply, inject, NS } from '../src/client/index.ts'
+import { ArchivedSessionsSection } from '../src/client/ArchivedSessionsSection.tsx'
+import type { ArchivedSessionsSectionInjected } from '../src/client/ArchivedSessionsSection.tsx'
+import { apply as hostApply } from '../src/index.ts'
+
+usePinnedBrowserLanguages('zh-CN')
+afterEach(cleanup)
+
+async function bench() {
+  const ctx = new Context()
+  await ctx.plugin(SlotRegistry).await()
+  const locale = new LocaleRuntime(ctx)
+  ctx.provide('locale', locale)
+  const unarchiveSession = vi.fn<(sessionId: SessionId) => Promise<void>>(async () => {})
+  ctx.provide('uiWorkspace', { unarchiveSession })
+  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, unarchiveSession }
+}
+
+function declare(slots: SlotRegistry): () => void {
+  return slots.register({
+    name: 'root',
+    children: { 'settings.section': { kind: 'list', scope: 'root' } },
+  } as never, () => null)
+}
+
+describe('ui-settings-unarchive-sessions browser plugin', () => {
+  it('keeps the host Loader entry inert', () => {
+    expect(hostApply).not.toThrow()
+  })
+
+  it('declares only the services used by the page and the archive write', () => {
+    expect(inject).toEqual(['slots', 'locale', 'uiWorkspace'])
+  })
+
+  it('registers the archived-session page last with localized copy', async () => {
+    const b = await bench()
+    declare(b.slots)
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+
+    const entry = b.slots.entries('settings.section')[0]!
+    expect(entry.component).toBe(ArchivedSessionsSection)
+    expect(entry.options).toMatchObject({ id: 'archived-sessions', order: 25 })
+    expect(entry.locale).toBe(NS)
+    expect(resolveSlotLabel(entry.options.label)).toBe('已归档会话')
+
+    b.locale.setLocale('en')
+    expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Archived sessions')
+
+    const injected = (entry.inject as unknown as () => ArchivedSessionsSectionInjected)()
+    await expect(injected.unarchive('session-one' as SessionId)).resolves.toBeUndefined()
+    expect(b.unarchiveSession).toHaveBeenCalledWith('session-one')
+    await b.ctx.fiber.dispose()
+  })
+
+  it('follows a late declaration and a declarer reload', async () => {
+    const b = await bench()
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    expect(b.slots.entries('settings.section')).toHaveLength(0)
+
+    const stop = declare(b.slots)
+    await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) })
+
+    stop()
+    expect(b.slots.entries('settings.section')).toHaveLength(0)
+    declare(b.slots)
+    await vi.waitFor(() => {
+      expect(b.slots.entries('settings.section')[0]?.component).toBe(ArchivedSessionsSection)
+    })
+
+    await fiber.dispose()
+    expect(b.slots.entries('settings.section')).toHaveLength(0)
+    expect(() => b.locale.register(NS, 'zh', {})).not.toThrow()
+    await b.ctx.fiber.dispose()
+  })
+})

+ 149 - 0
packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx

@@ -0,0 +1,149 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { ArchivedSessionsSection } from '../src/client/ArchivedSessionsSection.tsx'
+import type { ArchivedSessionsSectionProps } from '../src/client/ArchivedSessionsSection.tsx'
+import { en, type ArchivedSessionsLocaleKey } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const DAY = 86_400_000
+const sid = (id: string): SessionId => id as SessionId
+
+const t = ((key: ArchivedSessionsLocaleKey, params?: Record<string, string | number>): string =>
+  Object.entries(params ?? {}).reduce(
+    (text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
+    en[key],
+  )) as ArchivedSessionsSectionProps['t']
+
+function summary(id: string, title: string, updatedAt: number): SessionSummary {
+  return { id: sid(id), displayTitle: title, running: false, blank: false, updatedAt }
+}
+
+function sessionState(sessions: readonly SessionSummary[], phase: SessionListState['phase'] = 'ready'): SessionListState {
+  return {
+    ids: sessions.map(session => session.id),
+    byId: Object.fromEntries(sessions.map(session => [session.id, session])),
+    current: undefined,
+    phase,
+    subagentsByParent: {},
+    jobsBySession: {},
+    currentAddress: undefined,
+  }
+}
+
+function snapshot(archivedSessionIds: readonly string[], items: readonly WorkspaceView[] = []): WorkspaceSnapshot {
+  return { items, archivedSessionIds: archivedSessionIds.map(sid), state: 'idle', phase: 'ready', error: null }
+}
+
+function workspace(title: string, sessionIds: readonly string[]): WorkspaceView {
+  return {
+    workspaceId: title as WorkspaceView['workspaceId'],
+    path: `/work/${title}`,
+    title,
+    sessionIds: sessionIds.map(sid),
+    createdAt: '2026-01-01T00:00:00.000Z',
+    updatedAt: '2026-01-01T00:00:00.000Z',
+  }
+}
+
+function props(options: {
+  sessions: SessionListState
+  workspaces: WorkspaceSnapshot
+  unarchive?: (sessionId: SessionId) => Promise<void>
+}): ArchivedSessionsSectionProps {
+  return {
+    t,
+    unarchive: options.unarchive ?? (async () => {}),
+    useSessions: ((select: (state: SessionListState) => unknown) => select(options.sessions)),
+    useWorkspaces: ((select: (state: WorkspaceSnapshot) => unknown) => select(options.workspaces)),
+  } as unknown as ArchivedSessionsSectionProps
+}
+
+/** Two archived sessions in one Workspace, archived oldest first. */
+function twoRows(): ArchivedSessionsSectionProps {
+  return props({
+    sessions: sessionState([
+      summary('older', 'Older session', Date.now() - 2 * DAY),
+      summary('newer', 'Newer session', Date.now()),
+    ]),
+    workspaces: snapshot(['older', 'newer'], [workspace('Project', ['older', 'newer'])]),
+  })
+}
+
+describe('ArchivedSessionsSection', () => {
+  it('lists the newest archive first with its Workspace and last activity', () => {
+    render(<ArchivedSessionsSection {...twoRows()} />)
+
+    expect(screen.getAllByRole('listitem').map(row => row.textContent)).toEqual([
+      'Newer sessionProject · nowUnarchive',
+      'Older sessionProject · 2dUnarchive',
+    ])
+    expect(screen.getAllByRole('button', { name: /^Unarchive/ }).map(button => button.getAttribute('aria-label')))
+      .toEqual(['Unarchive Newer session', 'Unarchive Older session'])
+  })
+
+  it('labels a session outside every Workspace and hides an entry whose session is gone', () => {
+    render(<ArchivedSessionsSection {...props({
+      sessions: sessionState([summary('loose', 'Loose session', Date.now())]),
+      workspaces: snapshot(['gone', 'loose']),
+    })} />)
+
+    expect(screen.getAllByRole('listitem')).toHaveLength(1)
+    expect(screen.getByText('Loose session')).toBeTruthy()
+    expect(screen.getByText('Ungrouped · now')).toBeTruthy()
+  })
+
+  it('reads sessions until the list arrives, then reports an empty archive', () => {
+    const pending = render(<ArchivedSessionsSection {...props({
+      sessions: sessionState([], 'pending'),
+      workspaces: snapshot([]),
+    })} />)
+    expect(screen.getByText(en.loading)).toBeTruthy()
+    expect(screen.queryByRole('searchbox')).toBeNull()
+    pending.unmount()
+
+    render(<ArchivedSessionsSection {...props({ sessions: sessionState([]), workspaces: snapshot([]) })} />)
+    expect(screen.getByText(en.empty)).toBeTruthy()
+    expect(screen.queryByRole('listitem')).toBeNull()
+  })
+
+  it('filters by title and Workspace and reports a query with no match', () => {
+    render(<ArchivedSessionsSection {...twoRows()} />)
+    const search = screen.getByRole('searchbox', { name: en.search })
+
+    fireEvent.change(search, { target: { value: 'older' } })
+    expect(screen.getAllByRole('listitem')).toHaveLength(1)
+    expect(screen.getByText('Older session')).toBeTruthy()
+
+    fireEvent.change(search, { target: { value: 'project' } })
+    expect(screen.getAllByRole('listitem')).toHaveLength(2)
+
+    fireEvent.change(search, { target: { value: 'absent' } })
+    expect(screen.getByText(en.emptySearch)).toBeTruthy()
+    expect(screen.queryByRole('listitem')).toBeNull()
+    expect(screen.queryByText(en.empty)).toBeNull()
+  })
+
+  it('unarchives the clicked row and keeps a rejection as a console diagnostic', async () => {
+    const unarchive = vi.fn(async () => {})
+    render(<ArchivedSessionsSection {...twoRows()} unarchive={unarchive} />)
+    fireEvent.click(screen.getByRole('button', { name: 'Unarchive Newer session' }))
+    expect(unarchive).toHaveBeenCalledWith('newer')
+
+    cleanup()
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+    const failure = new Error('transport down')
+    render(<ArchivedSessionsSection {...props({
+      sessions: sessionState([summary('older', 'Older session', Date.now())]),
+      workspaces: snapshot(['older']),
+      unarchive: async () => { throw failure },
+    })} />)
+    fireEvent.click(screen.getByRole('button', { name: 'Unarchive Older session' }))
+    await waitFor(() => { expect(warn).toHaveBeenCalledWith('session unarchive rejected:', failure) })
+    warn.mockRestore()
+  })
+})

+ 39 - 0
packages/client/ui-settings-unarchive-sessions/tsconfig.json

@@ -0,0 +1,39 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
+    },
+    {
+      "path": "../ui-settings"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../ui-workspace"
+    },
+    {
+      "path": "../../core/session"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-settings-unarchive-sessions/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-settings-unarchive-sessions', ['lib/types/index.js'])

+ 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: 7317573add877fce9bdfdf359219053ae2452566
-README.zh.md: 1b803e3b91030171829d8a7f6f03270fa1aa566f
+README.md: 1edf9223c72e7094ce305734119975c3954cce59
+README.zh.md: 122f1d97a5a4dd7c5b54b0d9a27e323cdc6fb8ca

+ 1 - 1
packages/client/ui-workspace/README.md

@@ -106,7 +106,7 @@ None; this package neither assembles nor sends a provider request.
 These limits define the search depth, the archive surface, and the picking carrier; they are current package constraints.
 
 - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
-- **No Session deletion or unarchive control** — sessions can be archived, but archived sessions have no viewing or unarchive surface, and Workspace registration deletion does not delete Sessions.
+- **No Session deletion, and unarchive lives in Settings** — sessions can be archived but never deleted; the archived-sessions Settings page ([ui-settings-unarchive-sessions](../ui-settings-unarchive-sessions/README.md)) owns viewing and restoring them, and Workspace registration deletion does not delete Sessions.
 - **Pending user interaction is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded.
 - **Native folder selection depends on the local Host carrier** — under the `-native` composition, in-process or remote browser deployments cannot open a local operating-system dialog; remote-capable picking is the `-browse` composition's in-app flow.
 

+ 1 - 1
packages/client/ui-workspace/README.zh.md

@@ -106,7 +106,7 @@ Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Wor
 这些限制定义搜索深度、归档界面与选取载体;它们是当前包约束。
 
 - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。
-- **没有 Session 删除与取消归档控件**:会话可以归档,但已归档会话没有查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。
+- **没有 Session 删除,取消归档位于设置中**:会话可以归档但绝不会被删除;已归档会话的查看与恢复由「已归档会话」设置页([ui-settings-unarchive-sessions](../ui-settings-unarchive-sessions/README.zh.md))负责,删除 Workspace 注册记录不会删除 Session。
 - **待处理的用户交互不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。
 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,进程内部署或远程浏览器部署无法打开本地操作系统对话框;可远程的选取是 `-browse` 组合的应用内流程。
 

+ 9 - 0
packages/client/ui-workspace/src/client/navigation.ts

@@ -48,6 +48,11 @@ export interface UiWorkspace {
    * @param sessionId - Session to archive.
    */
   archiveSession(sessionId: SessionId): Promise<void>
+  /**
+   * Unarchive a Session, restoring it to its recorded Workspace position.
+   * @param sessionId - Session to unarchive.
+   */
+  unarchiveSession(sessionId: SessionId): Promise<void>
   /**
    * Open the Host-native directory picker.
    * @returns the selected directory, or null when cancelled.
@@ -176,6 +181,10 @@ class UiWorkspaceService extends Service implements UiWorkspace {
     await this.workspaces.archiveSession(sessionId)
   }
 
+  async unarchiveSession(sessionId: SessionId): Promise<void> {
+    await this.workspaces.unarchiveSession(sessionId)
+  }
+
   async pickDirectory(): Promise<string | null> {
     const result = await this.directoryPicker.pick()
     if (!result.ok) throw new Error(`directory picker failed: ${result.error.message}`)

+ 25 - 0
packages/client/ui-workspace/tests/workspaces-service.client.spec.ts

@@ -128,6 +128,7 @@ class FakeSessions {
 class FakeWorkspaces implements IWorkspaces {
   readonly list: MutableSource<WorkspaceSnapshot>
   readonly archiveCalls: SessionId[] = []
+  readonly unarchiveCalls: SessionId[] = []
   onArchive: IWorkspaces['archiveSession'] = async (sessionId) => {
     this.list.update(state => ({
       ...state,
@@ -135,6 +136,13 @@ class FakeWorkspaces implements IWorkspaces {
     }))
   }
 
+  onUnarchive: IWorkspaces['unarchiveSession'] = async (sessionId) => {
+    this.list.update(state => ({
+      ...state,
+      archivedSessionIds: state.archivedSessionIds.filter(id => id !== sessionId),
+    }))
+  }
+
   declare readonly create: IWorkspaces['create']
   declare readonly rename: IWorkspaces['rename']
   declare readonly delete: IWorkspaces['delete']
@@ -149,6 +157,11 @@ class FakeWorkspaces implements IWorkspaces {
     this.archiveCalls.push(sessionId)
     return this.onArchive(sessionId)
   }
+
+  unarchiveSession(sessionId: SessionId): Promise<void> {
+    this.unarchiveCalls.push(sessionId)
+    return this.onUnarchive(sessionId)
+  }
 }
 
 const listing: DirectoryListing = {
@@ -553,6 +566,18 @@ describe('UiWorkspaceService', () => {
     expect(b.workspaces.archiveCalls).toEqual([idle, idle])
   })
 
+  it('forwards unarchive commands and preserves failures', async () => {
+    const idle = sid('idle')
+    const b = bench()
+
+    await b.uiWorkspace.unarchiveSession(idle)
+    expect(b.workspaces.unarchiveCalls).toEqual([idle])
+
+    b.workspaces.onUnarchive = () => Promise.reject(new Error('unarchive rejected'))
+    await expect(b.uiWorkspace.unarchiveSession(idle)).rejects.toThrow('unarchive rejected')
+    expect(b.workspaces.unarchiveCalls).toEqual([idle, idle])
+  })
+
   it('passes directory operations to the Host and preserves structured browse failures', async () => {
     const b = bench()
     b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' })

+ 10 - 0
packages/extensions/cordis-client-runner/src/client/api-catalog.ts

@@ -359,6 +359,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         description: 'Archive a Session and clear it when it is the current selection.',
         parameters: [{ name: 'sessionId', description: 'Session to archive.' }],
       },
+      {
+        signature: 'unarchiveSession(sessionId: SessionId): Promise<void>',
+        description: 'Unarchive a Session, restoring it to its recorded Workspace position.',
+        parameters: [{ name: 'sessionId', description: 'Session to unarchive.' }],
+      },
       {
         signature: 'pickDirectory(): Promise<string | null>',
         description: 'Open the Host-native directory picker.',
@@ -406,6 +411,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         description: 'Archive a Session from Workspace grouping surfaces.',
         parameters: [{ name: 'sessionId', description: 'Session to archive.' }],
       },
+      {
+        signature: 'unarchiveSession(sessionId: SessionId): Promise<void>',
+        description: 'Unarchive a Session from the archived Session list.',
+        parameters: [{ name: 'sessionId', description: 'Session to unarchive.' }],
+      },
       {
         signature: 'insertSessionBefore( workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId, ): Promise<WorkspaceView>',
         description: 'Move a Session within one Workspace account.',

+ 16 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2953,6 +2953,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'request', description: 'Session identity to archive.' }],
         returns: 'the complete resulting archive set.',
       },
+      {
+        signature: '@Remote(\'unarchiveSession\') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue>',
+        description: 'Restore one archived Session to Workspace grouping surfaces.',
+        parameters: [{ name: 'request', description: 'Session identity to unarchive.' }],
+        returns: 'the complete resulting archive set.',
+      },
       {
         signature: '@Remote({ mode: \'stream\' }) follow(signal: AbortSignal): AsyncIterable<WorkspaceFollowFrame>',
         description: 'Stream a complete Workspace baseline followed by ordered increments.',
@@ -3051,6 +3057,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'sessionId', description: 'The session to archive.' }],
         returns: 'resolution after durability.',
       },
+      {
+        signature: 'unarchiveSession(sessionId: SessionId): Promise<void>',
+        description: 'Unarchive one session durably by dropping it from the registry-global archive set; the accounting slot was never touched, so the session returns to its recorded position. Unarchiving runs no session-existence check because removing an id cannot introduce an unknown one, so an entry whose session is gone still resolves. An id that is not archived resolves without writing.',
+        parameters: [{ name: 'sessionId', description: 'The session to unarchive.' }],
+        returns: 'resolution after durability.',
+      },
       {
         signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
         description: 'Resolve by canonical directory path without creating or mutating a workspace. A missing path rejects during `realpath`; an existing unowned directory returns `undefined`.',
@@ -6579,6 +6591,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'WorkspaceRenameRequest',
     declaration: 'export interface WorkspaceRenameRequest {\n    readonly workspaceId: WorkspaceId;\n    readonly title: string;\n}',
   },
+  {
+    name: 'WorkspaceUnarchiveSessionRequest',
+    declaration: 'export interface WorkspaceUnarchiveSessionRequest {\n    readonly sessionId: SessionId;\n}',
+  },
   {
     name: 'WorkspaceValue',
     declaration: 'export interface WorkspaceValue {\n    readonly workspace: WorkspaceView;\n}',

+ 17 - 0
packages/test-support/client-runtime/src/workspaces.ts

@@ -142,4 +142,21 @@ export class TestWorkspaces implements IWorkspaces {
       draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId]
     })
   }
+
+  /**
+   * Unarchive a session (recorded). The default mirrors the production face's
+   * observable effect: the id leaves the list state's archive set.
+   * @param sessionId - session to unarchive.
+   */
+  async unarchiveSession(sessionId: SessionId): Promise<void> {
+    this.calls.push({ method: 'unarchiveSession', args: [sessionId] })
+    const stub = this.stubs.get('unarchiveSession')
+    if (stub !== undefined) {
+      await (stub(sessionId) as Promise<void>)
+      return
+    }
+    await this.update((draft) => {
+      draft.archivedSessionIds = draft.archivedSessionIds.filter(id => id !== sessionId)
+    })
+  }
 }

+ 9 - 1
packages/test-support/client-runtime/tests/runtime.client.spec.tsx

@@ -488,8 +488,13 @@ describe('workspaces action face', () => {
     // state's archive set (features render against the same snapshot).
     await ws.archiveSession('s1' as SessionId)
     expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
+    await ws.archiveSession('s0' as SessionId)
+    // Default unarchive mirrors it: the id leaves the same set.
+    await ws.unarchiveSession('s0' as SessionId)
+    expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
     expect(ws.calls.map(c => c.method)).toEqual(
-      ['create', 'create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession'])
+      ['create', 'create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore',
+        'archiveSession', 'archiveSession', 'unarchiveSession'])
 
     ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
     ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
@@ -498,6 +503,7 @@ describe('workspaces action face', () => {
     ws.stub('insertBefore', insertBefore)
     ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
     ws.stub('archiveSession', () => Promise.resolve())
+    ws.stub('unarchiveSession', () => Promise.resolve())
     expect((await ws.create({ path: '/y' })).title).toBe('X')
     expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
     await ws.delete('w1' as WorkspaceId)
@@ -507,6 +513,8 @@ describe('workspaces action face', () => {
     // The stub replaces the default set mutation: the set stays as-is.
     await ws.archiveSession('s2' as SessionId)
     expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
+    await ws.unarchiveSession('s1' as SessionId)
+    expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
     await runtime.dispose()
   })
 })

+ 2 - 2
packages/workspace/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/workspace/workspace/README.md
-README.md: b5eaac5c68776c927cd464604f8d99d9290d9d54
-README.zh.md: 39cc0d9489bf5f687ee6a4e788c4462e4fabd1f0
+README.md: 8ac3bdbf0a69c031f1ddd5cdadb98f9cc4d55084
+README.zh.md: 225a24cc8fbedafe8e3ae6f64a849525eeb92067

+ 6 - 6
packages/workspace/workspace/README.md

@@ -25,7 +25,7 @@ Use this package to keep an ordered, persistent list of project directories and
 <a id="use-this-package"></a>
 ## Use this package
 
-Use this package to give the product a project list: named directories the user works in, the sessions that ran in each, a stable order, and a way to hide sessions without losing them. The API contracts behind each action live in the implementation section.
+Use this package to give the product a project list: named directories the user works in, the sessions that ran in each, a stable order, and a way to hide sessions without losing them or bring them back. The API contracts behind each action live in the implementation section.
 
 ### When to use it
 
@@ -63,9 +63,9 @@ ctx.workspaceRegistry.list() // shows the project, newest first
 
 A session joins the project of the directory it runs in: create a session in a project's directory and it appears under that project, newest first. A session can only belong to one project. A session whose directory cannot be validated — no recorded directory, or a moved or deleted folder — cannot join and stays ungrouped.
 
-### Hiding sessions and removing projects
+### Hiding and restoring sessions, and removing projects
 
-Hide a session from the grouping when it should stop appearing there: it disappears from the visible list, while its session, history, and place in the project stay intact. Remove a project when it is no longer needed: it leaves the list, and its folder, files, and session histories are never touched — those sessions become ungrouped. Adding the same directory again afterwards starts a fresh project without the old sessions.
+Hide a session from the grouping when it should stop appearing there: it disappears from the visible list, while its session, history, and place in the project stay intact. Restore a hidden session when it should appear again: it returns to its recorded position under its project, or to the ungrouped sessions when it belongs to none. Remove a project when it is no longer needed: it leaves the list, and its folder, files, and session histories are never touched — those sessions become ungrouped. Adding the same directory again afterwards starts a fresh project without the old sessions.
 
 -----
 
@@ -87,7 +87,7 @@ This section explains the design decisions behind the feature and points at the
 
 ### API behavior
 
-The API is one small family with two owners: `WorkspaceRegistry` creates, orders, and deletes projects and manages their session accounting; the `Workspace` entity exposes the display title, directory status, and the session projection. Per-method contracts live in the code, not this README — see [src/index.ts](src/index.ts) and [src/entity.ts](src/entity.ts).
+The API is one small family with two owners: `WorkspaceRegistry` creates, orders, and deletes projects, manages their session accounting, and archives or restores single sessions; the `Workspace` entity exposes the display title, directory status, and the session projection. Per-method contracts live in the code, not this README — see [src/index.ts](src/index.ts) and [src/entity.ts](src/entity.ts).
 
 ### Source map
 
@@ -102,7 +102,7 @@ The API is one small family with two owners: `WorkspaceRegistry` creates, orders
 
 ### Durable shape
 
-The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, and the optional `pendingMutation` marker. Records written before `archivedSessionIds` existed parse with an empty set through the schema default.
+The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, and the optional `pendingMutation` marker. Records written before `archivedSessionIds` existed parse with an empty set through the schema default. Archiving and unarchiving both rewrite only that global state, so a restore is one filtered write of the same field; unarchive runs no session-existence probe, because dropping an id from the set cannot introduce an unknown one, while archive verifies the session before adding it.
 
 ### Lifecycle
 
@@ -160,7 +160,7 @@ These limits define when the project list is a poor fit or needs special operati
 - **Removal never deletes data** — removing a project leaves its folder, files, and session histories in place; those sessions become ungrouped, and session deletion or folder removal are separate, absent capabilities ([decision](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)).
 - **A session joins only with a recorded directory** — a session belongs to a project only when its record carries a directory that resolves to the project's path; sessions without one stay ungrouped, and a session from another directory cannot be moved in.
 - **External changes are seen late** — if another process deletes or damages a directory, the project reflects it only at the next refresh or restart.
-- **Archiving is one-way** — a hidden session keeps its history and its place, but no unarchive action exists yet; the archive set is a durable display filter.
+- **Archive and unarchive enforce different session checks** — a restore only drops an id from the archive set, so an entry whose session is gone still unarchives and leaves no unknown referent; a restore of an id that is not archived resolves without writing, while `archiveSession` rejects a session that is neither live nor persisted.
 - **Re-adding a directory starts fresh** — after removal, adding the same directory again creates a new project with an empty session list; the old sessions do not come back automatically.
 
 <a id="dev-note"></a>

+ 6 - 6
packages/workspace/workspace/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-使用此包为产品提供项目列表:用户工作的命名目录、每个目录中运行的会话、稳定顺序,以及在不丢失会话的前提下将其隐藏的能力。每项操作背后的 API 约定放在实现章节中。
+使用此包为产品提供项目列表:用户工作的命名目录、每个目录中运行的会话、稳定顺序,以及在不丢失会话的前提下将其隐藏或重新取回的能力。每项操作背后的 API 约定放在实现章节中。
 
 ### 何时使用
 
@@ -63,9 +63,9 @@ ctx.workspaceRegistry.list() // shows the project, newest first
 
 会话加入它运行目录所在的项目:在项目目录中创建会话,它就会出现在该项目下,新到旧排列。一个会话只能属于一个项目。目录无法校验的会话——没有记录目录,或目录被移动、删除——无法加入,保持 Ungrouped。
 
-### 隐藏会话与移除项目
+### 隐藏、恢复会话与移除项目
 
-当会话不应再出现在分组中时隐藏它:它会从可见列表中消失,但其会话、历史与在项目中的位置都保持不变。项目不再需要时移除它:它离开列表,而其文件夹、文件与会话历史绝不受影响——这些会话变成 Ungrouped。之后再次添加同一目录会从空项目开始,不会带回旧会话。
+当会话不应再出现在分组中时隐藏它:它会从可见列表中消失,但其会话、历史与在项目中的位置都保持不变。当被隐藏的会话应重新出现时恢复它:它会回到其项目下记录的位置;不属于任何项目时则回到 Ungrouped。项目不再需要时移除它:它离开列表,而其文件夹、文件与会话历史绝不受影响——这些会话变成 Ungrouped。之后再次添加同一目录会从空项目开始,不会带回旧会话。
 
 -----
 
@@ -87,7 +87,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first
 
 ### API 行为
 
-该 API 是一个由两个所有者构成的小家族:`WorkspaceRegistry` 负责创建、排序与删除项目并管理其会话记账;`Workspace` 实体暴露显示标题、目录状态与会话投影。各方法的精确约定在代码中,而非本 README——参见 [src/index.ts](src/index.ts) 与 [src/entity.ts](src/entity.ts)。
+该 API 是一个由两个所有者构成的小家族:`WorkspaceRegistry` 负责创建、排序与删除项目、管理其会话记账,以及归档或恢复单个会话;`Workspace` 实体暴露显示标题、目录状态与会话投影。各方法的精确约定在代码中,而非本 README——参见 [src/index.ts](src/index.ts) 与 [src/entity.ts](src/entity.ts)。
 
 ### 源码地图
 
@@ -102,7 +102,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first
 
 ### 持久形态
 
-注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds` 与可选 `pendingMutation` 标记的全局状态。在 `archivedSessionIds` 存在之前写入的记录会通过 schema 默认值解析为空集合。
+注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds` 与可选 `pendingMutation` 标记的全局状态。在 `archivedSessionIds` 存在之前写入的记录会通过 schema 默认值解析为空集合。归档与取消归档都只重写该全局状态,因此恢复就是对同一字段的一次过滤写入;取消归档不做会话存在性探测,因为从集合中移除 id 不可能引入未知 id,而归档会在加入前校验会话。
 
 ### 生命周期
 
@@ -160,7 +160,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first
 - **移除绝不删除数据**——移除项目会保留其文件夹、文件与会话历史;这些会话变成 Ungrouped,而会话删除与文件夹移除是彼此独立且尚未提供的功能(参见[决策记录](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md))。
 - **只有带记录目录的会话才能加入**——只有记录中带有可解析为项目路径的目录的会话才属于项目;没有目录的会话保持 Ungrouped,来自其他目录的会话无法移入。
 - **外部变更延迟可见**——如果另一进程删除或损坏目录,项目只能在下次刷新或重启后反映出来。
-- **归档是单向的**——被隐藏的会话保留其历史与位置,但目前没有取消归档操作;归档集合是持久的显示过滤器。
+- **归档与取消归档执行不同的会话校验**——恢复只是从归档集合中移除 id,因此会话已不存在的条目仍能取消归档,也不会留下未知引用;对未归档 id 执行恢复不写盘即完成,而 `archiveSession` 会拒绝既非实时也未持久化的会话。
 - **重新添加目录从空开始**——移除后再次添加同一目录会创建空会话列表的新项目;旧会话不会自动回来。
 
 <a id="dev-note"></a>

+ 23 - 0
packages/workspace/workspace/src/index.ts

@@ -253,6 +253,29 @@ export class WorkspaceRegistry extends Service {
     })
   }
 
+  /**
+   * Unarchive one session durably by dropping it from the registry-global
+   * archive set; the accounting slot was never touched, so the session
+   * returns to its recorded position. Unarchiving runs no session-existence
+   * check because removing an id cannot introduce an unknown one, so an
+   * entry whose session is gone still resolves. An id that is not archived
+   * resolves without writing.
+   * @param sessionId - The session to unarchive.
+   * @returns resolution after durability.
+   */
+  unarchiveSession(sessionId: SessionId): Promise<void> {
+    return this.enqueueOperation(async () => {
+      // The chain slot serializes against every other registry write, so this
+      // check-then-write pair cannot interleave with a concurrent archive.
+      const state = this.requireState()
+      if (!state.archivedSessionIds.includes(sessionId)) return
+      await this.setState({
+        ...state,
+        archivedSessionIds: state.archivedSessionIds.filter(id => id !== sessionId),
+      })
+    })
+  }
+
   /**
    * Whether a session is live, header-indexed, or present in a fresh
    * persistence listing. Only a definite miss returns false — a failing

+ 60 - 0
packages/workspace/workspace/tests/workspace.spec.ts

@@ -973,3 +973,63 @@ describe('registry-global session archive', () => {
     expect(upgraded.registry.archivedSessionIds).toEqual([])
   })
 })
+
+describe('registry-global session unarchive', () => {
+  it('unarchives durably in order, idempotently skips absent ids, and leaves accounting untouched', async () => {
+    const dir = await makeDir('unarchive-home')
+    const result = await harness({
+      sessions: [header('one', dir, 100), header('two', dir, 200), header('three', dir, 300)],
+    })
+    const workspace = result.registry.list()[0]!
+    await result.registry.archiveSession(SessionId('one'))
+    await result.registry.archiveSession(SessionId('two'))
+    await result.registry.archiveSession(SessionId('three'))
+    expect(result.registry.archivedSessionIds).toEqual(['one', 'two', 'three'])
+
+    await result.registry.unarchiveSession(SessionId('two'))
+    // Removal keeps the survivors in archive order.
+    expect(result.registry.archivedSessionIds).toEqual(['one', 'three'])
+    // Unarchiving is a display-set write: the workspace account keeps the id.
+    expect(workspace.sessionIds).toContain('two')
+    expect(storedState(result.pool).archivedSessionIds).toEqual(['one', 'three'])
+    const changesAfterFirst = result.changes.filter(change => change.table === '').length
+
+    await result.registry.unarchiveSession(SessionId('two'))
+    expect(result.registry.archivedSessionIds).toEqual(['one', 'three'])
+    // The absent-id repeat neither rewrites the medium nor emits a change.
+    expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst)
+
+    await result.registry.unarchiveSession(SessionId('never-archived'))
+    expect(result.registry.archivedSessionIds).toEqual(['one', 'three'])
+    expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst)
+  })
+
+  it('unarchives an entry whose session is gone without consulting session persistence', async () => {
+    const dir = await makeDir('unarchive-vanished')
+    const result = await harness({ sessions: [header('vanished', dir, 100)] })
+    await result.registry.archiveSession(SessionId('vanished'))
+    result.setSessions([])
+    const listingsBefore = result.list.mock.calls.length
+    result.list.mockRejectedValueOnce(new Error('persistence backend down'))
+
+    // Removing an id cannot introduce an unknown one, so the archive entry
+    // resolves even though no session backs it and no listing runs.
+    await expect(result.registry.unarchiveSession(SessionId('vanished'))).resolves.toBeUndefined()
+    expect(result.registry.archivedSessionIds).toEqual([])
+    expect(result.list.mock.calls.length).toBe(listingsBefore)
+  })
+
+  it('keeps the surviving archive set across restarts', async () => {
+    const dir = await makeDir('unarchive-restart')
+    const pool = new MemoryMediaPool()
+    const sessions = [header('kept', dir, 100), header('restored', dir, 200)]
+    const first = await harness({ pool, sessions })
+    await first.registry.archiveSession(SessionId('kept'))
+    await first.registry.archiveSession(SessionId('restored'))
+    await first.registry.unarchiveSession(SessionId('restored'))
+    await first.fiber.dispose()
+
+    const second = await harness({ pool, sessions })
+    expect(second.registry.archivedSessionIds).toEqual(['kept'])
+  })
+})

+ 54 - 0
pnpm-lock.yaml

@@ -1913,6 +1913,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-settings-plugins':
         specifier: workspace:^
         version: link:../../client/ui-settings-plugins
+      '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions':
+        specifier: workspace:^
+        version: link:../../client/ui-settings-unarchive-sessions
       '@deepseek-ai/dsh-client-ui-sidebar':
         specifier: workspace:^
         version: link:../../client/ui-sidebar
@@ -3804,6 +3807,57 @@ importers:
         specifier: ^18.2.0
         version: 18.3.1
 
+  packages/client/ui-settings-unarchive-sessions:
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-api-session-controller':
+        specifier: workspace:^
+        version: link:../../api/session-controller
+      '@deepseek-ai/dsh-api-workspace-controller':
+        specifier: workspace:^
+        version: link:../../api/workspace-controller
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../../test-support/client-runtime
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
+      '@deepseek-ai/dsh-client-ui-renderer':
+        specifier: workspace:^
+        version: link:../ui-renderer
+      '@deepseek-ai/dsh-client-ui-session':
+        specifier: workspace:^
+        version: link:../ui-session
+      '@deepseek-ai/dsh-client-ui-settings':
+        specifier: workspace:^
+        version: link:../ui-settings
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-workspace':
+        specifier: workspace:^
+        version: link:../ui-workspace
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@testing-library/react':
+        specifier: ^16.1.0
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
+
   packages/client/ui-sidebar:
     devDependencies:
       '@deepseek-ai/cordis':

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -648,6 +648,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   WorkspaceInsertSessionBeforeRequest: 'workspace.md',
   WorkspaceOrderValue: 'workspace.md',
   WorkspaceRenameRequest: 'workspace.md',
+  WorkspaceUnarchiveSessionRequest: 'workspace.md',
   WorkspaceValue: 'workspace.md',
   ClientArtifactBaseline: 'client-modules.md',
   WebBootGraph: 'client-modules.md',

+ 3 - 3
scripts/gen-cordis-inspect-catalog.ts

@@ -16,10 +16,10 @@ const CLIENT_SERVICES: Readonly<Record<string, readonly string[]>> = {
   slots: ['register', 'inject'],
   theme: ['getTheme', 'setTheme', 'setFontSize', 'register', 'overrideTokens'],
   uiWorkspace: [
-    'openSession', 'openWorkspace', 'forkSession', 'connectWorkspace', 'startSession', 'archiveSession', 'pickDirectory', 'listDirectory',
-    'createDirectory',
+    'openSession', 'openWorkspace', 'forkSession', 'connectWorkspace', 'startSession', 'archiveSession', 'unarchiveSession',
+    'pickDirectory', 'listDirectory', 'createDirectory',
   ],
-  workspaces: ['create', 'rename', 'delete', 'insertSessionBefore', 'archiveSession'],
+  workspaces: ['create', 'rename', 'delete', 'insertSessionBefore', 'archiveSession', 'unarchiveSession'],
 }
 
 const CLIENT_EVENTS = new Set([

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -123,6 +123,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/ui-settings-plugin-inventory': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' },
+  'packages/client/ui-settings-unarchive-sessions': { kind: 'none', reason: 'Browser-side settings page; registers nothing model-facing.' },
   'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/context/file-reference': { kind: 'indirect', reason: 'The discovery seam and grammar delegate model guidance to the composed provider.' },

+ 1 - 0
tsconfig.base.json

@@ -231,6 +231,7 @@
       "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"],
       "@deepseek-ai/dsh-client-ui-settings-models": ["./packages/client/ui-settings-models/src"],
       "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"],
+      "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": ["./packages/client/ui-settings-unarchive-sessions/src"],
       "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"],
       "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"],
       "@deepseek-ai/dsh-schedule/client": ["./packages/schedule/schedule/src/client.ts"],

+ 1 - 0
tsconfig.client.json

@@ -110,6 +110,7 @@
     { "path": "./packages/client/ui-settings-general" },
     { "path": "./packages/client/ui-settings-models" },
     { "path": "./packages/client/ui-settings-plugin-inventory" },
+    { "path": "./packages/client/ui-settings-unarchive-sessions" },
     { "path": "./packages/client/locale" },
     { "path": "./packages/client/ui-renderer" },
     { "path": "./packages/client/ui-session" },

+ 1 - 0
tsconfig.host.json

@@ -48,6 +48,7 @@
     "apps/web/tests/workspace-new-session-folding.e2e.ts",
     "apps/web/tests/workspace-management.e2e.ts",
     "apps/web/tests/workspace-recency.e2e.ts",
+    "apps/web/tests/session-unarchive.e2e.ts",
     "apps/web/tests/replay-round-trip.e2e.ts",
     "apps/web/tests/hmr-live.e2e.ts",
     "apps/web/tests/preview-boot.e2e.ts",