From c764ed7e640a1c4efa67ba9063e37d12618c1603 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:40:14 +0800 Subject: [PATCH] feat(client-runtime): project the archive set and the archiveSession action WorkspaceListState gains archivedSessionIds (ReadonlySet, replaced only on membership change), installed as full snapshots from the list baseline, the unary echo, and the changed frame. Archiving the current session clears the selection into the New Session view state. Test doubles (test-runtime, fake APIs, fixture client) follow the widened IWorkspaces/IApiClient faces. --- .../client/connection/src/client/fixture.ts | 19 +++++++- packages/client/connection/tests/fake-api.ts | 5 +- .../client/locale/tests/language-row.spec.tsx | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + .../runtime/src/client/contract/workspaces.ts | 7 +++ .../runtime/src/client/workspaces/manager.ts | 30 ++++++++++++ .../runtime/src/client/workspaces/service.ts | 21 ++++++++- packages/client/runtime/tests/fake-api.ts | 16 ++++++- .../runtime/tests/workspaces-service.spec.ts | 46 +++++++++++++++++++ packages/client/test-runtime/src/fixtures.ts | 1 + .../client/test-runtime/src/workspaces.ts | 17 +++++++ .../tests/chat-code-subcalls.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- .../tests/terminal-card.spec.tsx | 4 +- .../client/ui-layout/tests/app-frame.spec.tsx | 2 +- .../ui-theme/tests/appearance-row.spec.tsx | 2 +- .../client/ui-trajectory/tests/views.spec.tsx | 2 +- 24 files changed, 177 insertions(+), 21 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 45445cc9cf..49f0aaf9dc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -969,6 +969,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { updatedAt: fixtureEpoch, }] let nextWorkspace = 1 + // Registry-global archive set mirroring the host: archived sessions keep + // their workspace accounting slot and only grouping surfaces hide them. + const archivedSessionIds: SessionId[] = [] // In-memory browse tree behind the fixture's `browse` picker capability — // deterministic content mirroring the design mock so assembled Web tests @@ -1623,7 +1626,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { openPath: request => ok(request, { opened: true as const }), }, workspace: { - list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), + list: request => ok(request, { + items: workspaces.map(w => ({ ...w })), + archivedSessionIds: [...archivedSessionIds], + }), create: (request) => { const { path, name } = request.payload const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` @@ -1709,6 +1715,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { workspace: { ...workspace } }) }, + archiveSession: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const { sessionId } = request.payload + if (!archivedSessionIds.includes(sessionId)) { + archivedSessionIds.push(sessionId) + emitHost({ type: 'host/archived-sessions-changed', archivedSessionIds: [...archivedSessionIds] }) + } + return ok(request, { archivedSessionIds: [...archivedSessionIds] }) + }, }, commands: { // The catalog mirrors one session's effective view (every fixture @@ -2089,6 +2105,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.rename': return this.api.workspace.rename(request) case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) + case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 0d58800279..d599de1be7 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -122,7 +122,7 @@ export class FakeApiClient implements IApiClient { } readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))), + list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))), create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true, @@ -134,6 +134,9 @@ export class FakeApiClient implements IApiClient { insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), + archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({ + archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId], + }))), } // Payloads stay `unknown` (lint-lane note above); response rows are the real diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index 2fdc5d5f45..223b9761b2 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 f42e4f09d0..60fcf831b4 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: 9f2b165f1a98dcecfa3ab82386da9b094cfd2f54 -README.zh.md: 3ed047e65d3bddc14c3b6b84f327bbeebf805d4b +README.md: 4c9122d87bfb0ea2d66478de03c69975b0577ab7 +README.zh.md: 1ee4731964ea75de29da47137933852581fc45b3 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 9f2b165f1a..4c9122d87b 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -10,6 +10,8 @@ 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 and, when the archived session is the current selection, clears it into the New Session view state; 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. `SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 3ed047e65d..1ee4731964 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -10,6 +10,8 @@ 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 视图状态。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 + SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 `SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 9238ea5fd0..3e64ef3717 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -76,4 +76,11 @@ export interface IWorkspaces { * @returns the updated Workspace view. */ insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise + /** + * Archive a session into the registry-global set (hidden from grouping + * surfaces; session log and accounting slot remain). Archiving the current + * session clears the selection into the New Session view state. + * @param sessionId - session to archive. + */ + archiveSession(sessionId: SessionId): Promise } diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ce4198cd01..0eddae8445 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -14,6 +14,8 @@ 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 state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -28,6 +30,9 @@ export class WorkspaceManager { private items: Workspace[] = [] private itemViewsSource: readonly Workspace[] | null = null 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 state: WorkspaceListSnapshot['state'] = 'idle' private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null @@ -77,6 +82,7 @@ export class WorkspaceManager { items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) + this.installArchived(result.value.archivedSessionIds) this.state = 'idle' this.phase = 'ready' } else { @@ -158,6 +164,18 @@ export class WorkspaceManager { return result } + /** + * Archive one session in the registry-global set, then install the + * returned full set without waiting for the changed frame. + * @param sessionId - session to archive. + * @returns the wire result. + */ + async archiveSession(sessionId: SessionId): Promise> { + const { result } = await this.api.workspace.archiveSession({ sessionId }) + if (result.ok) this.installArchived(result.value.archivedSessionIds) + return result + } + /** * Host-frame entry. Non-workspace frames are ignored so the runtime can * fan one host stream out to both object managers. @@ -166,6 +184,9 @@ export class WorkspaceManager { handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) + else if (envelope.payload.type === 'host/archived-sessions-changed') { + this.installArchived(envelope.payload.archivedSessionIds) + } } /** Re-pull the baseline after each connection generation. */ @@ -194,12 +215,21 @@ export class WorkspaceManager { private buildSnapshot(): WorkspaceListSnapshot { return { items: this.itemViews(), + archivedSessionIds: this.archivedSessionIds, state: this.state, phase: this.phase, error: this.error, } } + /** Replace the archive set when membership actually changed (set identity backs Object.is short-circuits). */ + private installArchived(archivedSessionIds: readonly SessionId[]): void { + if (archivedSessionIds.length === this.archivedSessionIds.size + && archivedSessionIds.every(id => this.archivedSessionIds.has(id))) return + this.archivedSessionIds = new Set(archivedSessionIds) + this.notifier.markDirty() + } + /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { if (this.removedIds.has(view.workspaceId)) return diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1dd3319e79..f3125e6494 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -14,6 +14,12 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts' /** Workspace list plus the two-baseline readiness and default-target projection. */ 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. + */ + archivedSessionIds: ReadonlySet state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -58,7 +64,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: [], state: 'idle', phase: 'pending', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'pending', error: null, baselinesReady: false, recentWorkspaceId: undefined, }) this.manager.subscribe(() => { this.project() }) @@ -249,6 +255,18 @@ export class WorkspacesService implements IWorkspaces { if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) } + /** + * Archive a session into the registry-global set. When the archived + * session is the current one, the selection is cleared into the New + * Session view state — a hidden row must not stay open behind the list. + * @param sessionId - session to archive. + */ + async archiveSession(sessionId: SessionId): Promise { + const result = await this.manager.archiveSession(sessionId) + if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`) + if (this.sessions.list.getSnapshot().current === sessionId) this.sessions.clear() + } + /** * Move a session within its Workspace's manual order (DOM-insertBefore-like). * @param workspaceId - owning workspace. @@ -293,6 +311,7 @@ export class WorkspacesService implements IWorkspaces { const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready' this.list.set({ items: workspace.items, + archivedSessionIds: workspace.archivedSessionIds, state: workspace.state, phase: workspace.phase, error: workspace.error, diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 06d948ae83..9cf7971049 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -140,7 +140,10 @@ export class FakeApiClient implements IApiClient { openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } - onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + // The archive-set field defaults at the binding below so list stubs keep + // the pre-archive `{ items }` shape; a stub carrying the field wins. + onWorkspaceList: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [] })) onWorkspaceCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) @@ -153,13 +156,22 @@ export class FakeApiClient implements IApiClient { onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + onWorkspaceArchiveSession: (payload: unknown) => Promise> = + payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) + readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), + list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => ( + response.result.ok + ? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } } + : response + )) as ReturnType), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), + archiveSession: (payload: unknown) => + this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)), } // Payloads stay `unknown` (lint-lane note above); response rows are the real diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 7fd6934827..2bd462e5ee 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -285,6 +285,52 @@ describe('WorkspacesService', () => { })) await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) }) + + it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onList = () => Promise.resolve(ok({ + items: [ + { sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false }, + { sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false }, + ], + }) as never) + await sessions.refresh() + sessions.open(sid('s-open')) + + // 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(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(sessions.list.getSnapshot().current).toBeUndefined() + + // A Host failure leaves the set and the selection untouched. + api.onWorkspaceArchiveSession = () => Promise.resolve(err({ + 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']) + + // The changed frame and the list baseline both re-install the full set. + workspaces.handleHostEnvelope({ + rpcId: 'frame' as never, + payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] }, + } 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']) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never) + await workspaces.refresh() + expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open']) + }) }) describe('startInitialSelection', () => { diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 4219d233e2..d81adf7698 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -73,6 +73,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot export function workspaceListState(): WorkspaceListState { return { items: [], + archivedSessionIds: new Set(), 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 6c1a9d0aad..fab209c96c 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -186,4 +186,21 @@ export class TestWorkspaces implements IWorkspaces { if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise) return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView } + + /** + * Archive a session (recorded). The default mirrors the production face's + * observable effect: the id joins the list state's archive set. + * @param sessionId - session to archive. + */ + async archiveSession(sessionId: SessionId): Promise { + this.calls.push({ method: 'archiveSession', args: [sessionId] }) + const stub = this.stubs.get('archiveSession') + if (stub !== undefined) { + await (stub(sessionId) as Promise) + return + } + await this.update((draft) => { + draft.archivedSessionIds = new Set([...draft.archivedSessionIds, sessionId]) + }) + } } 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 49eeec29fd..16d070b04d 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 bfaab93375..b59335fa3a 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 354b94ecdd..192470abfc 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 6831b1a5a0..1974881357 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 f619d380a0..4901ec2f9f 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 60b02795fe..167b9c96af 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 a37074bd07..2669cbb8ab 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, state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: new Set(), 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 213897e0f5..29f246d603 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 ab8a11706d..d30cd17877 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 f21fb26bdd..00cc0e3052 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: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), 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 5c1e6c691d..d31b43d3e6 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: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store)