diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml index ace89283b1..cca8f8a597 100644 --- a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.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/feature/2026-07-31-session-archive-global-set.md -2026-07-31-session-archive-global-set.md: b318be9985b197171a046a5abeab3c93b879c710 -2026-07-31-session-archive-global-set.zh.md: 8e3157f4c55c54c3fdd3132034b28567b9b25013 +2026-07-31-session-archive-global-set.md: fab99a405a6f8264c36453473327e32905bac9c8 +2026-07-31-session-archive-global-set.zh.md: e33f3b5272a6d8fc90cfad247ba21d4d10afb045 diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md index b318be9985..fab99a405a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md @@ -15,7 +15,7 @@ The session row menu in the sidebar workspace browser carried a purely visual "D - 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 its `sessionIds` slot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant. - Registry: `ctx.workspace.archiveSession(id)` rides `enqueueOperation`, serialized with create/delete; a session neither live nor persisted throws `WorkspaceUnknownSessionError`; an already archived id neither writes nor emits. The `archivedSessionIds` getter exposes the read-only set. - RPC: `workspace.archiveSession({sessionId}) → {archivedSessionIds}` (answers the full updated set); the `workspace.list` response carries the set as the reconnect baseline; a new host frame `host/archived-sessions-changed` pushes the full snapshot after every durable change (same posture as `host/workspace-changed`, emitted from the `domain/changed` global-put branch by set comparison). Unknown sessions reuse the `session-not-found` error code. -- Client runtime: `WorkspaceListState.archivedSessionIds` (a `ReadonlySet`, reference replaced only on membership change); 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-flight `workspace.list` also shields the newer set from the stale baseline. +- Client runtime: `WorkspaceListState.archivedSessionIds` (a `readonly 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-flight `workspace.list` also shields the newer set from the stale baseline. - UI: the `delete` menu row (visual-only) becomes `archive` (label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm in `tree.ts`'s `sessionVisible` predicate, with `deriveGroups`/`deriveFlat` taking an `archived` set parameter so all four surfaces (group loop, stray bucket, search, flat) share one source. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md index 8e3157f4c5..e33f3b5272 100644 --- a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md @@ -15,7 +15,7 @@ Sidebar workspace 浏览区的 session 行菜单里,「Delete session」一直 - 存储:`archivedSessionIds: z.array(sessionId).default([])`,domain version 保持 2——纯增量字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的 session 保留其 `sessionIds` 席位(未来取消归档恢复原位置),因此与「一个 session 只被一个 workspace 记账」不变式零纠缠。 - Registry:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知 session(实时与持久化都查不到)抛 `WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。 - RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答完整更新后集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知 session 复用错误码 `session-not-found`。 -- client runtime:`WorkspaceListState.archivedSessionIds`(`ReadonlySet`,成员不变不换引用);list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。 +- client runtime:`WorkspaceListState.archivedSessionIds`(按 Host 顺序的 `readonly SessionId[]`,成员不变不换引用——公有快照状态保持 store 引擎的纯数据词汇:immer draft 不开 MapSet 插件就不接受 Set;membership 查询在派生函数内自建临时 Set,与 expandedProjects 同款);list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。 - UI:菜单项 `delete`(visual-only)改为 `archive`(label「Archive session」,非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为 `tree.ts` 的 `sessionVisible` 判据加一档,`deriveGroups`/`deriveFlat` 增加 `archived` 集合入参,四个视图(分组循环、stray 兜底、搜索、平铺)同源生效。 ## 已考虑的替代方案 diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index 223b9761b2..2908583191 100644 --- a/packages/client/locale/tests/language-row.spec.tsx +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -21,7 +21,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 34f1a33cc5..60d011aeb1 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 52d2caf51d2fdaf48a06e6d56292fe087568eba5 -README.zh.md: d7e807cd7dffdd81a106488884031fccbb8a6d65 +README.md: 022dc6f82ea7aa1490144449ea61a84a512906a2 +README.zh.md: 4d0f74f573a5e03b05755cfcfea930e69ef386e2 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 52d2caf51d..022dc6f82e 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -10,7 +10,7 @@ Workspace and Session lists have independent monotone `pending` → `ready` base `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. -`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `ReadonlySet` replaced only when membership changes). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. +`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index d7e807cd7d..4d0f74f573 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -10,7 +10,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 -`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个 `ReadonlySet`,仅在成员变化时才替换)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 +`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ba8344533a..ccf0c46fe1 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -14,8 +14,14 @@ export type WorkspaceListPhase = 'pending' | 'ready' /** Immutable workspace-list snapshot. */ export interface WorkspaceListSnapshot { items: readonly WorkspaceView[] - /** Registry-global archive set (hidden from grouping surfaces; accounting slots retained). */ - archivedSessionIds: ReadonlySet + /** + * Registry-global archive set in Host order (hidden from grouping + * surfaces; accounting slots retained). A plain array, not a Set: public + * snapshot state stays in the store engine's plain-data vocabulary + * (immer drafts reject Sets without the MapSet plugin); membership + * lookups build their own transient Set where they need one. + */ + archivedSessionIds: readonly SessionId[] state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -32,7 +38,7 @@ export class WorkspaceManager { private itemViewsCache: readonly WorkspaceView[] = [] // Full-snapshot state (list response / unary response / changed frame all // carry the complete set), so deltas never merge — installs replace. - private archivedSessionIds: ReadonlySet = new Set() + private archivedSessionIds: readonly SessionId[] = [] private state: WorkspaceListSnapshot['state'] = 'idle' private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null @@ -230,12 +236,16 @@ export class WorkspaceManager { } } - /** Replace the archive set when membership actually changed (set identity backs Object.is short-circuits). */ + /** + * Replace the archive set when membership actually changed (array identity + * backs Object.is short-circuits). Host snapshots are append-ordered, so + * positional comparison is exact, not merely heuristic. + */ private installArchived(archivedSessionIds: readonly SessionId[]): void { if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true - if (archivedSessionIds.length === this.archivedSessionIds.size - && archivedSessionIds.every(id => this.archivedSessionIds.has(id))) return - this.archivedSessionIds = new Set(archivedSessionIds) + if (archivedSessionIds.length === this.archivedSessionIds.length + && archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return + this.archivedSessionIds = [...archivedSessionIds] this.notifier.markDirty() } diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 2ef6f77867..a0a76670f2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -15,11 +15,13 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts' export interface WorkspaceListState { items: readonly WorkspaceView[] /** - * Registry-global archive set: grouping surfaces hide these sessions - * everywhere (workspace groups and the ungrouped bucket) while their - * session logs and workspace accounting slots remain. + * Registry-global archive set in Host order: grouping surfaces hide these + * sessions everywhere (workspace groups and the ungrouped bucket) while + * their session logs and workspace accounting slots remain. A plain array + * (store-engine vocabulary; immer drafts reject Sets) — membership lookups + * build their own transient Set. */ - archivedSessionIds: ReadonlySet + archivedSessionIds: readonly SessionId[] state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -64,7 +66,7 @@ export class WorkspacesService implements IWorkspaces { constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'pending', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null, baselinesReady: false, recentWorkspaceId: undefined, }) this.manager.subscribe(() => { this.project() }) @@ -101,7 +103,7 @@ export class WorkspacesService implements IWorkspaces { for (const id of sessions.ids) { const summary = sessions.byId[id] if (summary !== undefined && summary.blank && summary.cwd === workspace.path - && !archived.has(summary.id)) return summary.id + && !archived.includes(summary.id)) return summary.id } const attempt = this.sessions.create({ workspaceId }) .finally(() => { this.connecting.delete(workspaceId) }) @@ -317,7 +319,7 @@ export class WorkspacesService implements IWorkspaces { // every install path with one rule: the local unary echo, another tab's // changed frame, and a reconnect baseline restoring a persisted // selection that was archived while this client was away. - if (sessions.current !== undefined && workspace.archivedSessionIds.has(sessions.current)) { + if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) { this.sessions.clear() } this.list.set({ diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 71769a8e79..4323d7ffce 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -309,13 +309,13 @@ describe('WorkspacesService', () => { // Archiving a non-current session installs the unary echo and keeps the selection. await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined() expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }]) - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle']) expect(sessions.list.getSnapshot().current).toBe('s-open') // Archiving the current session clears it into the New Session view state. api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] })) await workspaces.archiveSession(sid('s-open')) - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle', 's-open']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open']) expect(sessions.list.getSnapshot().current).toBeUndefined() // A Host failure leaves the set and the selection untouched. @@ -323,7 +323,7 @@ describe('WorkspacesService', () => { code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') }, })) await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/) - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle', 's-open']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open']) // The changed frame and the list baseline both re-install the full set. workspaces.handleHostEnvelope({ @@ -332,10 +332,10 @@ describe('WorkspacesService', () => { } as never) // Frame installs ride the notifier's microtask batch before projecting. await new Promise(resolve => setTimeout(resolve, 0)) - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle']) api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never) await workspaces.refresh() - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open']) }) it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => { @@ -363,11 +363,11 @@ describe('WorkspacesService', () => { expect(sessions.list.getSnapshot().current).toBeUndefined() gate.resolve(ok({ items: [], archivedSessionIds: [] })) await hydration - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open']) // The next (fresh) baseline is authoritative again. api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never) await workspaces.refresh() - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual([]) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([]) }) }) diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index d81adf7698..e4c9ef946d 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -73,7 +73,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot export function workspaceListState(): WorkspaceListState { return { items: [], - archivedSessionIds: new Set(), + archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index d27139cd48..7e626a3660 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -199,11 +199,8 @@ export class TestWorkspaces implements IWorkspaces { await (stub(sessionId) as Promise) return } - // Built outside the draft: reading a Set through an immer draft needs - // the MapSet plugin, while assigning a fresh Set does not. - const next = new Set([...this.list.getSnapshot().archivedSessionIds, sessionId]) await this.update((draft) => { - draft.archivedSessionIds = next + draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId] }) } } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index f48e96b832..3675671f26 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -554,7 +554,7 @@ describe('workspaces action face', () => { // Default archive mirrors the production effect: the id joins the list // state's archive set (features render against the same snapshot). await ws.archiveSession('s1' as SessionId) - expect([...ws.list.getSnapshot().archivedSessionIds]).toEqual(['s1']) + expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession']) @@ -573,7 +573,7 @@ describe('workspaces action face', () => { expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) // 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']) + expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) await runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 16d070b04d..1f9b8b7356 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -128,7 +128,7 @@ async function bench(snapshot: ConversationSnapshot) { ctx.provide('sessions', sessionsFake) const workspaces = { list: createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }), startSession: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b59335fa3a..ca81d225a9 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -94,7 +94,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 192470abfc..4de44d367c 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -74,7 +74,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( @@ -111,7 +111,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 1974881357..e8154ca957 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -96,7 +96,7 @@ function bench(over?: BenchOptions) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: ((key: string, selector?: (v: unknown) => unknown) => diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 4901ec2f9f..04f20d56c6 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -39,7 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 167b9c96af..11ee8fa4e4 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -125,7 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 2669cbb8ab..3841930d2c 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -62,7 +62,7 @@ function workspace(id = 'w1'): WorkspaceView { } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 29f246d603..71de19b152 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -429,7 +429,7 @@ describe('DetailsPanel Output section', () => { phase: 'ready', }) const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render( @@ -607,7 +607,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }))} useWorkspaces={bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }))} useInput={(() => { throw new Error('unused') })} diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index d30cd17877..11b5e48e0a 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -77,7 +77,7 @@ function mountFrame() { return sel(sessionState) }) as never const workspaceState: WorkspaceListState = { - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: baselinesReady.current, recentWorkspaceId: undefined, } const element = () => ( diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx index 00cc0e3052..d028affd69 100644 --- a/packages/client/ui-theme/tests/appearance-row.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -27,7 +27,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index d31b43d3e6..1ebe8d2f61 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -113,7 +113,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 5b12646a27..e5c89ce0de 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -35,7 +35,7 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }) -const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: ReadonlySet = new Set()): WorkspaceListState => ({ +const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = []): WorkspaceListState => ({ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) @@ -149,7 +149,7 @@ describe('WorkspaceBrowser', () => { expect(archiveSession).toHaveBeenCalledWith(sid('gone-s')) // The archive-set echo hides the row in grouped mode (count included) and flat mode. - rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], new Set([sid('gone-s')]))) }) + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) }) expect(screen.queryByText('gone-s')).toBeNull() expect(screen.getByText('1 个会话')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '分组方式' })) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 334020b37f..b2d1479d17 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -32,7 +32,7 @@ const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) function anchor(): { current: HTMLElement } {