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