mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 兜底、搜索、平铺)同源生效。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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') })}
|
||||
|
||||
@@ -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 = () => (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '分组方式' }))
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
Reference in New Issue
Block a user