Session archive (registry-global set)
会话归档(注册表级全局集合)
The session row menu in the sidebar workspace browser carried a purely visual "Delete session" placeholder (no handler). The product decision is **archive**, not delete: the session log and its workspace accounting stay untouched; the session merely disappears from every grouping surface (workspace groups, Ungrouped, search, the flat list). The archive record needs a home: an Ungrouped session belongs to no workspace
English
Problem
The session row menu in the sidebar workspace browser carried a purely visual "Delete session" placeholder (no handler). The product decision is archive, not delete: the session log and its workspace accounting stay untouched; the session merely disappears from every grouping surface (workspace groups, Ungrouped, search, the flat list). The archive record needs a home: an Ungrouped session belongs to no workspace entity, so a per-workspace field cannot carry it.
Decision
The archive set is a new field on the workspace domain's global singleton (workspaceDomainState.archivedSessionIds), layered over workspace accounting; display filtering converges entirely in the client's tree.ts derivation layer; the wire surface uses the full-snapshot posture.
- Storage:
archivedSessionIds: z.array(sessionId).default([]), domain version stays 2 — a purely additive field; pre-field media parse to an empty set through the schema default, no migration code. An archived session keeps itssessionIdsslot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant. - Registry:
ctx.workspaceRegistry.archiveSession(id)ridesenqueueOperation, serialized with create/delete; a session neither live nor persisted throwsWorkspaceUnknownSessionError; an already archived id neither writes nor emits. ThearchivedSessionIdsgetter exposes the read-only set. - RPC:
workspace.archiveSession({sessionId}) → {archivedSessionIds}(answers the full updated set); theworkspace.listresponse carries the set as the reconnect baseline; a new host framehost/archived-sessions-changedpushes the full snapshot after every durable change (same posture ashost/workspace-changed, emitted from thedomain/changedglobal-put branch by set comparison). Unknown sessions reuse thesession-not-founderror code. - Client runtime:
WorkspaceListState.archivedSessionIds(areadonly SessionId[]in Host order, reference replaced only on membership change — public snapshot state stays in the store engine's plain-data vocabulary since immer drafts reject Sets without the MapSet plugin; membership lookups build a transient Set in the derivation, the expandedProjects pattern); the list baseline, the unary echo, and the changed frame each install the complete set. the projection sweep clears the current selection whenever it lands in the archive set, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero) — one rule covering the local unary echo, another tab's changed frame, and a reconnect baseline restoring a selection archived while this client was away; a frame or echo landing during an in-flightworkspace.listalso shields the newer set from the stale baseline. - UI: the
deletemenu row (visual-only) becomesarchive(label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm intree.ts'ssessionVisiblepredicate, withderiveGroups/deriveFlattaking anarchivedset parameter so all four surfaces (group loop, stray bucket, search, flat) share one source.
Alternatives considered
Per-workspace archivedSessionIds (the original phrasing). Rejected: Ungrouped sessions have no home; the user switched to global.
An archived flag on SessionSummary (session.list layer). Rejected: it joins a workspace-domain fact into the sessions-domain projection, summaries have no incremental frame so a separate notification would still be needed — cross-domain coupling outweighs the saving.
Host-side filtering in workspaceView/the sessionIds getter. Rejected: archiving ≠ changing accounting, and filtering the projection muddles the two concepts; a future restore surface also needs the client to see full accounting.
Incremental frames (single archived/removed rows). Rejected: the set is tiny and changes rarely; full snapshots spare the client merge logic and dedup state and match the existing workspace-changed posture.
Consequences
Archived sessions have no viewing or unarchive surface yet (this iteration's scope; recorded as a README Known Limitation); data and accounting slots stay intact, so a future restore is one UI surface plus one inverse RPC. The workspace.list response shape change is a pre-release direct edit (no compatibility layer). The workspace-management e2e pins the full chain (archive → row disappears → still hidden after reload, log still present); domain tests pin idempotence, unknown-id rejection, restart recovery, and the pre-field media default upgrade.
中文
问题
Sidebar workspace 浏览区的会话行菜单里,「Delete session」一直是纯视觉占位(无 handler)。产品口径定为归档而非删除:会话日志与 workspace 记账都不动,只把该会话从所有分组视图(workspace 分组、Ungrouped、搜索、平铺列表)里隐藏。归档记录需要一个落点:Ungrouped 的会话不属于任何 workspace 实体,per-workspace 字段放不下它。
决策
归档集合是 workspace domain 全局单例(workspaceDomainState.archivedSessionIds)上的一个新字段,覆盖在 workspace 记账之上;显示过滤全部收敛在 client 的 tree.ts 派生层;wire 面走全快照姿态。
- 存储:
archivedSessionIds: z.array(sessionId).default([]),domain version 保持 2——纯新增字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的会话保留其sessionIdsslot(未来取消归档恢复原位置),因此与「一个会话只被一个 workspace 记账」不变式零纠缠。 - 注册表:
ctx.workspaceRegistry.archiveSession(id)走enqueueOperation与 create/delete 串行;未知会话(实时与持久化都查不到)抛WorkspaceUnknownSessionError;已归档 id 不写盘不发事件。archivedSessionIdsgetter 暴露只读集合。 - RPC:
workspace.archiveSession({sessionId}) → {archivedSessionIds}(应答更新后的完整集合);workspace.list响应携带集合作为重连基线;新 host 帧host/archived-sessions-changed在每次持久变更后推完整快照(与host/workspace-changed同姿态,从domain/changed的 global put 分支比对推帧)。未知会话复用错误码session-not-found。 - client 运行时:
WorkspaceListState.archivedSessionIds(按 Host 顺序的readonly SessionId[],成员不变不换引用——公有快照状态保持 store 引擎的纯数据词汇:immer draft 不开 MapSet 插件就不接受 Set;membership 查询在派生函数内自建临时 Set,与 expandedProjects 同款);list 基线、unary 回声、changed 帧三路都会用完整集合整体替换现有值。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的会话会使主视图回到 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线发现当前 selection 已在此 client 离线期间被归档的情形;帧/回声落在 in-flightworkspace.list期间时还会屏蔽旧基线对新集合的回滚。 - UI:菜单项
delete(visual-only)改为archive(label「Archive session」,非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为tree.ts的sessionVisible判据加一档,deriveGroups/deriveFlat增加archived集合入参,四个视图(分组循环、stray 兜底、搜索、平铺)同源生效。
已考虑的替代方案
per-workspace archivedSessionIds(最初表述)。 否决:Ungrouped 会话无落点;用户改口全局。
SessionSummary 打 archived 标(session.list 层)。 否决:要把 workspace domain 事实 join 进 sessions domain 投影,summary 无增量帧还得另发通知,跨域耦合大于收益。
host 侧在 workspaceView/sessionIds getter 过滤。 否决:归档 ≠ 改记账,投影过滤会把两个概念搅浑;未来恢复入口也需要 client 拿到全量记账。
增量帧(archived/removed 单条)。 否决:集合极小、变更频率低,全快照免去 client 侧合并逻辑与去重状态,与 workspace-changed 现有姿态一致。
后果
归档后 UI 无查看/取消归档入口(本期口径,记录在 README 的 Known Limitation 中);数据与 slot 完好,后续加恢复面只是 UI + 一个逆向 RPC。workspace.list 响应形状变化是 pre-release 直改(无兼容层)。e2e(workspace-management)钉住了「归档→行消失→reload 后仍隐藏、日志仍在」的全链路;domain 层测试钉住幂等、未知 id 拒绝、跨重启恢复与旧介质默认升级。