refactor(client): archivedSessionIds public face becomes a plain array

Public snapshot state stays in the store engine's plain-data vocabulary
(immer drafts reject Sets without the MapSet plugin, which stays off):
manager/service/contract carry readonly SessionId[] in Host order, and
the tree derivations build their own transient Set — the
expandedProjects pattern. Membership-unchanged installs still keep the
array reference for Object.is short-circuits.
This commit is contained in:
imccyu
2026-07-31 10:40:09 +08:00
committed by imccyu
parent 5fc2645afc
commit 9ed87a6dba
26 changed files with 62 additions and 53 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 插件就不接受 Setmembership 查询在派生函数内自建临时 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 兜底、搜索、平铺)同源生效。
## 已考虑的替代方案

View File

@@ -21,7 +21,7 @@ function emptySessions() {
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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` 的裸 observableweb-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。

View File

@@ -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<SessionId>
/**
* 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<SessionId> = 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()
}

View File

@@ -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<SessionId>
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<WorkspaceListState>({
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({

View File

@@ -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([])
})
})

View File

@@ -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,

View File

@@ -199,11 +199,8 @@ export class TestWorkspaces implements IWorkspaces {
await (stub(sessionId) as Promise<void>)
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]
})
}
}

View File

@@ -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()
})
})

View File

@@ -128,7 +128,7 @@ async function bench(snapshot: ConversationSnapshot) {
ctx.provide('sessions', sessionsFake)
const workspaces = {
list: createSnapshotStore<WorkspaceListState>({
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(),

View File

@@ -94,7 +94,7 @@ function emptySessions() {
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
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)

View File

@@ -74,7 +74,7 @@ describe('render branch tails', () => {
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
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<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
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(

View File

@@ -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) =>

View File

@@ -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),

View File

@@ -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),

View File

@@ -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,
})

View File

@@ -429,7 +429,7 @@ describe('DetailsPanel Output section', () => {
phase: 'ready',
})
const workspaces = createSnapshotStore<WorkspaceListState>({
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<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
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') })}

View File

@@ -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 = () => (

View File

@@ -27,7 +27,7 @@ function emptySessions() {
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
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)

View File

@@ -113,7 +113,7 @@ function emptySessions() {
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
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)

View File

@@ -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<SessionId> = 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: '分组方式' }))

View File

@@ -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 } {