fix(workspace): unify pin IDs and preserve complete session order
This commit is contained in:
1 parent
f1e06a69ef
commit
7baccf5d85
69 files changed
+956
-543
No files matched your search
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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-09-12-session-unarchive-settings-page.md
|
||||
2026-09-12-session-unarchive-settings-page.md: 3b1e364e3abc6cea0a8b29ad78abb0ff7c274dbe
|
||||
2026-09-12-session-unarchive-settings-page.zh.md: 61e108dd50e61b9e6e9f72098973c067e3257407
|
||||
@@ -1,45 +0,0 @@
|
||||
# Agent Note: Session unarchive Settings page
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-12-session-unarchive-settings-page.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Archiving a Session removed it from every Workspace grouping surface, and nothing brought it back. The archive set is a durable display filter, so a hidden Session kept its log, its Workspace accounting slot, and its position, but the only recovered route was editing the domain state by hand. The archived feature record predicted the restore surface as "one UI surface plus one inverse RPC"; both now exist, and the Client had no place that listed archived Sessions at all.
|
||||
|
||||
## Decision
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession(sessionId)` drops one id from the registry-global `archivedSessionIds` set in a single durable `setState`, running on the same operation chain as archive, create, and delete. The idempotent check-then-write pair sits inside one chain slot, so a concurrent archive cannot interleave and a lost race resolves as a no-op. An id that is not archived resolves without writing.
|
||||
|
||||
Unarchiving runs no session-existence probe. Archive verifies that the Session is live or persisted because it adds a reference that must resolve; unarchive only removes an id, so it cannot introduce an unknown referent and an entry whose Session is gone still restores. `@Remote('unarchiveSession')` on `WorkspaceController` returns the complete `WorkspaceArchiveValue`, matching `archiveSession`, and `IWorkspaces.unarchiveSession` plus `UiWorkspace.unarchiveSession` carry the verb to the browser. Both verbs answer with the complete set, so `ClientWorkspaceModel` installs a reply only while it is still the latest archive-set request: a later request, or a set pushed by the follow stream, supersedes an in-flight answer and keeps the projection on the newer state.
|
||||
|
||||
The new Web Settings page `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` owns the surface. It registers one localized `settings.section` contribution with id `archived-sessions` at nav order 25, joins the archive set from `useWorkspaces` with the loaded Session summaries from `useSessions`, lists the newest archive first with the owning Workspace title or the ungrouped label and a relative last-activity time, filters by title or Workspace name, and offers one Unarchive action per row. A rejected write is logged as a console diagnostic and leaves the row for another attempt. An archive entry whose Session summary is missing produces no row, so the page never renders an action that cannot restore anything.
|
||||
|
||||
Restoring is a page whose subject is archived Sessions, and the archived Sessions themselves are hidden from every grouping surface, so no Session row can host the action. The archived-sessions page joins the Settings navigation beside General, Models, and Plugins, whose rail already carries the archive glyph this page declares in `SettingsRoot`.
|
||||
|
||||
The `{ type: 'archived', archivedSessionIds }` follow increment already carries the complete set, so a restore reuses it: the Remote answer installs locally, and every other Client converges through the same increment. No frame type, persisted field, or `SESSION_FORMAT_VERSION` change accompanies the verb.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**An Unarchive action in the Session row menu.** The row menu owns Archive, but a restored Session has no visible row until it is restored, so the action would have nowhere to live in the state that needs it; a disabled entry on every row would be decoration without a subject.
|
||||
|
||||
**A second frame type carrying a removal delta.** The existing `archived` increment is a complete-set replacement, so a removal delta would add a redundant representation that every consumer would have to merge; the full-snapshot posture is what lets the archive echo be reused unchanged.
|
||||
|
||||
**Rendering an archive entry whose Session is gone as a disabled row.** Such a row could explain the gap but not restore anything, and the registry and the Remote still accept the id, so the page lists what it can act on and leaves the orphan id to a future cleanup surface.
|
||||
|
||||
**A Session-existence probe on unarchive, mirroring archive.** The check exists to keep added references resolvable, and a removal cannot break that invariant; probing would only turn restoring a deleted history into a failure with nothing to repair.
|
||||
|
||||
## Consequences
|
||||
|
||||
The archive set stays the only durable state a restore rewrites; the `workspace` domain version, the Session log, and the Workspace accounting slot are untouched, and a restored Session returns to its recorded position. Archive and unarchive now enforce different Session checks, a deliberate asymmetry recorded in the [Workspace registry limitations](../../../../packages/workspace/workspace/README.md#known-limitations-and-deferred-work).
|
||||
|
||||
The restore surface is bounded by what the page can display: an archived id whose summary is not loaded has no row and no Unarchive action even though the registry method and the Remote accept it, so the page reports an unavailable set instead of an empty archive. Restoring through automation stays available, and a future cleanup surface can address the remaining orphan entries.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/workspace/workspace/tests/workspace.spec.ts` pins the registry method: removal keeps the surviving archive order, the accounting slot stays, a repeat and a never-archived id neither rewrite the medium nor emit a change, an entry whose Session is gone resolves without a persistence listing, and the surviving set reloads across a restart. `packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` pins the verb's idempotent complete-set answer and the unchanged `archived` follow increment, and `packages/api/workspace-controller/tests/model.client.spec.ts` pins the Client model echo and its refusal to install a failed answer, plus the four stale-reply races: overlapping archive or unarchive requests settling out of order, and a follow increment or baseline arriving while a reply is in flight. `packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` pins newest-first ordering, the ungrouped label, the hidden entry whose Session is gone, the reading and empty states, search, and the console diagnostic on rejection, while `tests/browser-plugin.client.spec.tsx` pins the section registration.
|
||||
|
||||
## Related
|
||||
|
||||
- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set, the follow increment, and the predicted restore surface.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Agent Note: Session unarchive Settings page
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-12-session-unarchive-settings-page.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
归档会话会把它从每一个 Workspace 分组界面中移除,而没有任何东西能把它带回来。归档集合是持久的显示过滤器,因此被隐藏的会话保留其日志、Workspace 记账位置与所在位置,但唯一的恢复途径是手工编辑领域状态。归档功能记录把恢复界面预测为「一个 UI 界面加一个反向 RPC」;两者如今都已存在,而 Client 此前根本没有列出已归档会话的地方。
|
||||
|
||||
## 决策
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession(sessionId)` 在一次持久化 `setState` 中把某个 id 从注册表全局的 `archivedSessionIds` 集合里移除,并与 archive、create、delete 跑在同一条操作链上。幂等的「先检查再写入」位于同一个链槽内,因此并发归档无法插入其间,输掉竞态的一方会解析为无操作。未被归档的 id 不写盘即解析完成。
|
||||
|
||||
取消归档不做会话存在性探测。归档会校验会话处于实时或已持久化状态,因为它加入的引用必须可解析;取消归档只是移除一个 id,因此不可能引入未知引用,会话已不存在的条目也仍然能恢复。`WorkspaceController` 上的 `@Remote('unarchiveSession')` 返回完整的 `WorkspaceArchiveValue`,与 `archiveSession` 一致,而 `IWorkspaces.unarchiveSession` 与 `UiWorkspace.unarchiveSession` 把该动词带到浏览器。两个动词都返回完整集合,因此 `ClientWorkspaceModel` 只在该应答仍是最新归档集合请求时安装它:更晚的请求或 follow 流推送的集合都会取代在途应答,让投影停留在更新的状态上。
|
||||
|
||||
新的 Web 设置页 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` 拥有该界面。它注册一个 id 为 `archived-sessions`、导航顺序为 25 的本地化 `settings.section` 贡献,把来自 `useWorkspaces` 的归档集合与来自 `useSessions` 的已加载 Session 摘要合并,按归档时间由新到旧列出,并显示为其记账的 Workspace 标题或未分组标签,以及相对最近活动时间;它按标题或 Workspace 名称过滤,并为每行提供一个取消归档操作。写入被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。缺失 Session 摘要的归档条目不产生行,因此该页面绝不会渲染无法恢复任何东西的操作。
|
||||
|
||||
恢复操作所在的页面以已归档会话为主题,而已归档会话本身从每一个分组界面中隐藏,因此没有任何 Session 行能承载该操作。「已归档会话」页在设置导航中与「通用」「模型」「插件」并列,而该导航轨道本就带有本页在 `SettingsRoot` 中声明的归档字形。
|
||||
|
||||
`{ type: 'archived', archivedSessionIds }` follow 增量本就携带完整集合,因此恢复复用它:Remote 应答在本地安装,其他每个 Client 都通过同一个增量收敛。该动词不伴随任何帧类型、持久字段或 `SESSION_FORMAT_VERSION` 变更。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在 Session 行菜单放一个取消归档操作。** 行菜单拥有 Archive,但被恢复的会话在恢复之前没有可见的行,因此该操作在真正需要它的状态里无处安放;给每一行加一个禁用条目只是没有主体的装饰。
|
||||
|
||||
**新增一种携带移除增量的帧类型。** 现有的 `archived` 增量是完整集合替换,因此移除增量会增加一种每个消费方都必须合并的冗余表示;正是全快照姿态让归档回声得以原样复用。
|
||||
|
||||
**把会话已不存在的归档条目渲染为禁用行。** 这样的行可以解释缺口,却无法恢复任何东西,而注册表与 Remote 仍然接受该 id,因此该页面只列出它能操作的条目,把遗留 id 留给未来的清理界面。
|
||||
|
||||
**像归档一样,在取消归档时做会话存在性探测。** 该检查的存在是为了让新增的引用保持可解析,而移除不可能破坏这一不变式;探测只会把恢复已删除的历史变成一次无从修复的失败。
|
||||
|
||||
## 后果
|
||||
|
||||
归档集合仍是恢复唯一重写的持久状态;`workspace` 领域版本、会话日志与 Workspace 记账位置都不受影响,被恢复的会话回到其记录的位置。归档与取消归档现在执行不同的会话校验,这是刻意的不对称,并记录在 [Workspace registry 的限制](../../../../packages/workspace/workspace/README.zh.md#known-limitations-and-deferred-work)中。
|
||||
|
||||
恢复界面受该页面能显示的内容限制:摘要未加载的已归档 id 没有行也没有取消归档操作,尽管注册表方法与 Remote 都接受它;因此页面报告没有可恢复条目,而不是把归档集合说成空的。通过自动化恢复仍然可用,未来的清理界面可以处理剩余的遗留条目。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/workspace/workspace/tests/workspace.spec.ts` 固定注册表方法:移除后幸存的归档顺序保持不变、记账位置仍在、重复取消归档与从未归档的 id 既不重写介质也不发出变更、会话已不存在的条目无需持久化列表即可解析、幸存集合在重启后重新加载。`packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` 固定该动词幂等的完整集合应答与不变的 `archived` follow 增量,`packages/api/workspace-controller/tests/model.client.spec.ts` 固定 Client model 的回声及其拒绝安装失败应答的行为,以及四种过期应答竞态:归档或取消归档请求重叠且乱序返回,以及应答在途时到达的 follow 增量或 baseline。`packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` 固定由新到旧的排序、未分组标签、会话已不存在条目的隐藏、读取与空状态、搜索,以及被拒绝时的 console 诊断,`tests/browser-plugin.client.spec.tsx` 则固定分区注册。
|
||||
|
||||
## 相关
|
||||
|
||||
- [会话归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md)——归档集合、follow 增量与被预测的恢复界面的冻结记录。
|
||||
+2
-2
@@ -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-09-18-session-pin-and-sidebar-archive.md
|
||||
2026-09-18-session-pin-and-sidebar-archive.md: 58775e067c0fdb76a0e4d2d79ea0a730a57a336d
|
||||
2026-09-18-session-pin-and-sidebar-archive.zh.md: db17183c178b111c3035d12ca68df3b19848fd84
|
||||
2026-09-18-session-pin-and-sidebar-archive.md: eb0c82ba84b1ebc94b23bff4d613ec6de9565d71
|
||||
2026-09-18-session-pin-and-sidebar-archive.zh.md: d971c7b4ff2b6de6a0f49c41974b7654843b29ea
|
||||
@@ -8,25 +8,72 @@ English | [中文](2026-09-18-session-pin-and-sidebar-archive.zh.md)
|
||||
|
||||
The sidebar session list had no way to keep important sessions at the top, and archived sessions could only be reached through a settings page (`ui-settings-unarchive-sessions`) far from the list they came from. Users archived a session and then lost sight of it.
|
||||
|
||||
A visibility filter can hide a Session without removing it from its Workspace. Saving filtered rows as the account's complete order loses hidden positions. Independent pin ordering also makes dragging depend on a second sequence instead of the Session order the user edits.
|
||||
|
||||
## Decision
|
||||
|
||||
The `dsh-workspace` registry stores a registry-global pin set (`pinnedSessions`, most recently pinned first, each entry carrying its epoch-millisecond `pinnedAt`) beside the existing archive set, with durable `pinSession`/`unpinSession` operations. Pinning and archival are mutually exclusive: archiving drops the session's pin in the same durable write, and pinning an archived session fails with `WorkspaceArchivedSessionPinError`.
|
||||
The `dsh-workspace` registry stores registry-global pin and archive sets as Session id arrays (`pinnedSessionIds` and `archivedSessionIds`), with durable `pinSession`/`unpinSession` operations. The pin array keeps the most recently pinned id first. Pinning and archival are mutually exclusive: archiving drops the session's pin in the same durable write, and pinning an archived session fails with `WorkspaceArchivedSessionPinError`.
|
||||
|
||||
The sidebar derives both states in `ui-workspace`:
|
||||
### Complete Session order
|
||||
|
||||
Each browser-local account has one complete Session sequence, including pinned and archived members. Workspace membership bounds each account; registry-global pin and archive sets never add a Session to an unrelated Workspace. Grouped, Ungrouped, and flat views use the same rules.
|
||||
|
||||
- Existing members retain their positions. Missing pinned members prepend in the pin array's order. Other missing members append by descending Session `updatedAt`, with missing archived members last. Supplementation never interleaves existing positions or duplicates an id.
|
||||
- Supplementation is an in-memory projection, not an automatic storage migration. A Session-order write records the complete supplemented sequence, including hidden archived rows. Filtering alone never rewrites Session positions.
|
||||
- A successful Pin action marks the Session pinned and moves its position to the front of its complete account sequence. Completion uses current saved positions and the latest Workspace membership, preserving reorders completed while the request was pending. Unpin removes the mark without restoring an earlier position. Both pinned and ordinary drags edit the same complete sequence; display indices never replace its hidden members.
|
||||
- The current blank New Session precedes both partitions; other pinned Sessions lead unpinned Sessions. Manual preserves the complete sequence's relative order within both partitions. Last updated sorts both partitions strictly by descending `updatedAt`, with Session id as the tie-break. Pin records carry only Session identities, not timestamps.
|
||||
- Archive takes precedence if both sets contain a Session. Archiving clears the Host pin but retains the Session's account position. The archive filter changes visibility and disabled-row presentation, not membership or ordering.
|
||||
|
||||
The existing [mode-switch and provisional-New-Session rules](../simplification/2026-09-10-derived-workspace-recency.md) remain: a drag selects Manual, Last updated discards the prior manual layout, entering Manual captures the current chronological order, and the selected blank Session retains its provisional first slot. An explicit Pin updates saved positions without changing the selected mode; chronological display still ignores those positions.
|
||||
|
||||
### Archive restoration
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession` removes one id from the global archive set on the same serialized operation chain as archive and other registry writes. An absent id is an idempotent no-op with no durable write or notification. Restoration preserves Workspace membership and Session logs, and it neither restores a pin nor changes saved ordering.
|
||||
|
||||
Unarchive performs no Session-existence probe: removing an id cannot introduce an unknown referent, so even an entry whose Session is gone can be removed without listing persistence. The Remote returns the complete archive set, and the existing `archived` increment carries the same state to other Clients. A Client installs a unary reply only while it remains the latest archive-set request; a newer request, follow increment, or replacement baseline supersedes it. Failed writes leave the set unchanged, and the sidebar logs the rejection while retaining the row.
|
||||
|
||||
### Archived visibility and row motion
|
||||
|
||||
The sidebar derives its visible rows in `ui-workspace`:
|
||||
|
||||
- Pinned rows lead their section as a partition that never disturbs relative order on either side, so unpinning restores the row's kept slot. Under recency order a pinned row ranks by the later of its pin instant and update recency; under manual order pinned-to-pinned drags hold.
|
||||
- Archived rows keep their accounting slots, render grayed, and are not openable. The view-options menu owns an `ArchivedFilter` (`default` hide / `show` / `only`) applied to lists and search alike; the settings-page archived list is deleted with its package.
|
||||
- Archiving raises a toast with undo and filter-archived actions, and a one-time hint anchors under the view-options trigger.
|
||||
- Archiving raises a toast with undo and filter-archived actions. Parent rerenders do not extend its lifetime; the fully faded banner cannot retain clickable actions.
|
||||
- Row motion uses a private React component with native position and opacity animations. It captures row positions around structural commits without observers or polling; removed rows leave inert fading copies outside the scrolling list. Initial loading, drag commits, overflow expansion/collapse, and grouping/ordering/filter switches settle immediately.
|
||||
|
||||
### Persistence
|
||||
|
||||
Workspace KV, Remote values, and Client snapshots store archive and pin membership as Session id arrays, without object wrappers or pin timestamps. Missing collections default to empty; object-form pin entries are not accepted. Pinning and restoration do not change the Workspace domain version or Session-log format, and neither operation edits Session logs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the settings-page archived list.** Two homes for one state; the sidebar filter shows archived rows in the browsing context they came from, so the package is removed outright.
|
||||
|
||||
**Fold pin position into the persisted manual order.** The order would absorb pin state, so unpinning could not restore the row's previous slot; the partition keeps pin state and row order independent.
|
||||
**A restore action without an archived-row filter.** Hidden rows cannot expose actions. The filter makes those rows reachable, so restoration can live with the Session list instead of a separate settings page.
|
||||
|
||||
**A separate restore delta or an existence check on unarchive.** A second frame type duplicates the complete-set response and requires another merge path. An existence probe would turn removal of an orphan archive id into an error even though removal cannot introduce an unresolved id. Restoration therefore shares the archive-set protocol and omits the probe.
|
||||
|
||||
**Render archive ids whose Session summaries are missing.** Such entries could explain orphan state, but they cannot provide the normal conversation row. The sidebar displays loaded summaries; direct restoration remains available through the API, and orphan management remains outside this browser.
|
||||
|
||||
**A separately editable pin sequence.** This duplicates ordering authority and requires another drag write path. The global pin array only seeds missing pinned members; established positions belong to the complete Session sequence.
|
||||
|
||||
**Restore pre-pin or pre-archive positions.** Keeping Pin separate from manual positions would restore pre-pin slots, but Pin is an intentional reorder. A separate restoration record adds a product guarantee that this interaction does not require; hiding an archived row already preserves its existing position.
|
||||
|
||||
**Rank pins by their pin timestamp.** This makes Last updated mean something other than Session activity. A newly pinned old Session need not lead the pinned partition in that mode; Manual shows the Pin action's first position.
|
||||
|
||||
**Persist every derived or filtered list.** Rendering and visibility changes are not ordering commands. An incomplete or filtered projection cannot replace the complete saved sequence.
|
||||
|
||||
**Animate every list pass.** Revealing hidden rows, committing a drag, or switching the filter moves rows wholesale and reads as exaggerated motion; those passes apply instantly while pin jumps, archive fades, and neighboring-row movement retain their animations.
|
||||
|
||||
## Consequences
|
||||
|
||||
The sidebar owns archived visibility end to end and settings loses a package. The pin set is registry-global, so pins survive grouping and ordering switches. Row animation owns no persistent state or dependency. Unit suites cover the partition, filter, toast, and animation lifecycle; the workspace Host suite covers pin/archive exclusivity and durability.
|
||||
The sidebar owns archived visibility end to end and settings loses a package. The pin set is registry-global, so pins survive grouping and ordering switches. Row animation owns no persistent state or dependency.
|
||||
|
||||
Filtering and reordering share one complete membership sequence, so hidden archives survive drags and filter switches. Older saved orders recover omitted members in memory and adopt the supplemented sequence on their next Session-order write. Host pin/archive membership semantics remain unchanged. The browser gives up pin-time promotion in Last updated and automatic restoration of pre-pin positions.
|
||||
|
||||
An archived id without a loaded Session summary has no visible row or sidebar restore action, even though the API can remove it. This UI limit does not change the registry's idempotent restoration behavior.
|
||||
|
||||
Unit suites cover supplementation, complete-sequence dragging, Pin completion after intervening changes, New Session priority, archived navigation guards, filters, toast lifetime, and animation lifecycle. Workspace and Controller tests cover pin/archive exclusivity, identity-only persistence and snapshots, idempotent no-op restoration, missing-Session restoration without persistence probes, restart recovery, and stale unary replies superseded by newer requests or pushed state. The keyless sidebar restore scenario verifies the row action and reload durability.
|
||||
|
||||
## Related
|
||||
|
||||
- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set and its follow increment.
|
||||
@@ -8,25 +8,72 @@ Status: implemented
|
||||
|
||||
侧边栏会话列表无法把重要会话固定在顶部,已归档会话只能通过远离列表的设置页(`ui-settings-unarchive-sessions`)访问。用户归档一个会话之后就再也看不到它了。
|
||||
|
||||
显隐筛选可以隐藏会话,但不改变它的 Workspace 归属。把筛选后的行保存成所属列表的完整顺序,会丢失隐藏项的位置。单独维护可编辑的置顶顺序,也会让拖拽依赖另一份序列,而不是用户正在编辑的会话顺序。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-workspace` registry 在既有归档集合旁存储一个 registry 全局置顶集合(`pinnedSessions`,最近置顶在前,每项携带毫秒级 `pinnedAt`),并提供持久化的 `pinSession`/`unpinSession` 操作。置顶与归档互斥:归档会在同一次持久化写入中移除该会话的置顶,置顶已归档会话则以 `WorkspaceArchivedSessionPinError` 失败。
|
||||
`dsh-workspace` registry 将全局置顶与归档集合存为会话 id 数组(`pinnedSessionIds` 和 `archivedSessionIds`),并提供持久化的 `pinSession`/`unpinSession` 操作。置顶数组把最近置顶的 id 放在前面。置顶与归档互斥:归档会在同一次持久化写入中移除该会话的置顶,置顶已归档会话则以 `WorkspaceArchivedSessionPinError` 失败。
|
||||
|
||||
侧边栏在 `ui-workspace` 中派生这两种状态:
|
||||
### 完整会话顺序
|
||||
|
||||
每份浏览器本地排序记录包含一条完整会话序列,包括置顶和归档成员。每份序列受 Workspace 成员关系约束,全局置顶与归档集合不会把会话补进无关的 Workspace。分组、Ungrouped 和平铺视图使用同一套规则。
|
||||
|
||||
- 已有成员保留位置。缺失的置顶成员按置顶数组中的顺序补到头部。其他缺失成员按会话 `updatedAt` 降序追加,缺失的归档成员放在最后。补齐不穿插已有位置,也不重复加入同一个 id。
|
||||
- 补齐是内存派生,不自动迁移存储。一次会话顺序写入保存补齐后的完整序列,包括隐藏的归档行。单纯切换筛选不重写会话位置。
|
||||
- 置顶成功后,会话被标记为置顶,同时移到所属完整序列的首位。完成时使用当前保存的位置与最新 Workspace 成员关系,保留请求等待期间完成的重排序。取消置顶只移除标记,不恢复更早的位置。置顶行与普通行的拖拽都修改同一条完整序列,显示下标不会覆盖其中的隐藏成员。
|
||||
- 当前空白新会话排在两个分区之前,其余会话再按置顶区、非置顶区排列。手动模式下,两区分别保持完整序列中的相对顺序。最近更新模式下,两区都严格按 `updatedAt` 降序排序,以会话 id 打破平局。置顶记录只包含会话身份,不包含时间戳。
|
||||
- 会话同时命中两个集合时,归档优先。归档会清除 Host 的置顶标记,但保留会话在所属序列中的位置。归档筛选只改变可见性和禁用行的展示,不改变成员关系或排序。
|
||||
|
||||
既有的[模式切换与临时新会话规则](../simplification/2026-09-10-derived-workspace-recency.zh.md)保持不变:拖拽选择手动模式,最近更新丢弃先前的手动布局,进入手动模式记录当前时间顺序,当前选中的空白会话保留临时首位。显式置顶更新保存的位置,但不切换所选模式;时间视图仍忽略这些位置。
|
||||
|
||||
### 取消归档
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession` 与归档及其他注册表写入共用串行操作链,从全局归档集合移除一个 id。id 不在集合中时是幂等空操作,不产生持久写入或通知。恢复保留 Workspace 成员关系与 Session 日志,不恢复置顶,也不改变保存的顺序。
|
||||
|
||||
取消归档不探测 Session 是否存在:移除 id 不会引入未知引用,因此即使 Session 已不存在,也能在不列举持久化记录的情况下移除归档项。Remote 返回完整归档集合,既有 `archived` 增量把同一状态传给其他 Client。Client 只在一元响应仍属于最新归档集合请求时安装它;更新的请求、follow 增量或替换基线会覆盖旧响应的资格。写入失败不改变集合,侧边栏记录拒绝原因并保留原行。
|
||||
|
||||
### 归档显隐与行动画
|
||||
|
||||
侧边栏在 `ui-workspace` 中派生可见行:
|
||||
|
||||
- 置顶行以分区方式排在所在区块最前,分区不打乱两侧的相对顺序,因此取消置顶会恢复行原有的位置。最近更新排序下,置顶行按置顶时间与更新时间中较晚者排名;手动排序下,置顶行之间的拖拽顺序保持。
|
||||
- 已归档行保留其记账位置,置灰渲染且不可打开。视图选项菜单持有 `ArchivedFilter`(`default` 隐藏 / `show` / `only`),同时作用于列表与搜索;设置页的归档列表连同其包一并删除。
|
||||
- 归档动作弹出携带撤销与筛选已归档操作的 toast,并在视图选项触发按钮下方锚定一次性提示。
|
||||
- 归档动作弹出携带撤销与筛选已归档操作的 toast。父组件重渲染不会延长其生命周期,完全淡出后不会保留可点击操作。
|
||||
- 行动画由私有 React 组件使用浏览器原生位移与透明度动画实现。组件在结构性提交前后记录行位置,不使用观察器或轮询;被移除的行在滚动列表外留下不可交互的淡出副本。初始加载、拖拽提交、展开/收起其余会话,以及分组/排序/筛选切换直接完成。
|
||||
|
||||
### 持久化
|
||||
|
||||
Workspace KV、Remote 值与 Client 快照都用会话 id 数组存储归档和置顶成员,不包含对象包装或置顶时间戳。缺失集合默认为空,不接受对象形式的置顶条目。置顶与恢复不改变 Workspace 领域版本或 Session 日志格式,也不编辑 Session 日志。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留设置页的归档列表。** 同一状态出现两个入口;侧边栏筛选让已归档行显示在它们原本所在的浏览上下文中,因此该包被整体移除。
|
||||
|
||||
**把置顶位置并入持久化的手动排序。** 排序会吸收置顶状态,取消置顶后无法恢复行先前的位置;分区让置顶状态与行顺序保持独立。
|
||||
**只提供恢复操作,不提供已归档筛选。** 隐藏行无法提供操作。筛选让这些行可以被访问,因此恢复可以与 Session 列表放在一起,而不需要独立设置页。
|
||||
|
||||
**独立恢复增量,或在取消归档时检查存在性。** 新增帧类型会重复完整集合响应,并增加合并路径。存在性探测会把移除孤立归档 id 变成错误,尽管移除不可能引入未解析 id。因此恢复复用归档集合协议,不执行该探测。
|
||||
|
||||
**显示缺少 Session 摘要的归档 id。** 这些条目可以解释孤立状态,但不能提供普通对话行。侧边栏显示已加载摘要,API 仍支持直接恢复;孤立项管理不属于当前浏览器。
|
||||
|
||||
**单独维护可编辑的置顶序列。** 这会产生两个排序来源,也需要另一条拖拽写入路径。全局置顶数组只为缺失置顶成员提供初始位置,已有位置由完整会话序列管理。
|
||||
|
||||
**恢复置顶或归档之前的位置。** 让置顶独立于手动位置可以恢复置顶前的位置,但置顶本身就是一次有意的重排序。另存恢复记录增加了这套交互不需要的产品保证;隐藏归档行本身已经保留其已有位置。
|
||||
|
||||
**用置顶时间给置顶行排名。** 这会让最近更新不再严格表达会话活动。在该模式下,新置顶的旧会话不必排在置顶区首位;手动模式才显示置顶操作调整后的首位。
|
||||
|
||||
**保存每次派生或筛选后的列表。** 渲染和显隐变化不是排序命令。不完整或经过筛选的投影不能覆盖完整的已保存序列。
|
||||
|
||||
**为每次列表变化播放动画。** 显示隐藏行、提交拖拽或切换筛选会整体移动行,观感夸张;这些变化即时应用,而置顶跳动、归档淡出和相邻行补位仍保留动画。
|
||||
|
||||
## Consequences
|
||||
|
||||
侧边栏端到端拥有已归档可见性,设置页少了一个包。置顶集合是 registry 全局的,因此置顶在分组与排序切换后仍然保留。行动画不持有持久状态,也不引入依赖。单元测试覆盖分区、筛选、toast 与动画生命周期;workspace Host 测试覆盖置顶/归档互斥与持久化。
|
||||
侧边栏端到端拥有已归档可见性,设置页少了一个包。置顶集合是 registry 全局的,因此置顶在分组与排序切换后仍然保留。行动画不持有持久状态,也不引入依赖。
|
||||
|
||||
筛选与重排序共用一条完整成员序列,隐藏的归档项不会因拖拽或切换筛选丢失。旧排序遗漏的成员在内存中补齐,并在下一次会话顺序写入时随完整序列保存。Host 的置顶与归档成员语义保持不变。浏览器不再用置顶时间提升最近更新模式下的位置,也不自动恢复置顶前的位置。
|
||||
|
||||
归档 id 没有已加载 Session 摘要时,不会产生可见行或侧边栏恢复操作,尽管 API 仍能移除它。这个 UI 限制不改变注册表的幂等恢复行为。
|
||||
|
||||
单元测试覆盖补齐、完整序列拖拽、期间发生其他变更后的 Pin 完成、新会话优先级、归档导航保护、筛选、toast 生命周期与行动画生命周期。Workspace 和 Controller 测试覆盖置顶与归档互斥、只含身份的持久化及快照、幂等空操作恢复、缺失 Session 时不探测持久化的恢复、重启恢复,以及被新请求或推送状态取代的旧一元响应。无密钥的侧边栏恢复场景验证行操作与重载后的持久性。
|
||||
|
||||
## 相关
|
||||
|
||||
- [Session 归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md) — 归档集合及其 follow 增量的冻结记录。
|
||||
+2
-2
@@ -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/simplification/2026-09-10-derived-workspace-recency.md
|
||||
2026-09-10-derived-workspace-recency.md: e8f304110c89bf563164dd24125beeb36e7644a6
|
||||
2026-09-10-derived-workspace-recency.zh.md: 60f2da36c277e77d367f152aa8a3159ff68d909c
|
||||
2026-09-10-derived-workspace-recency.md: b967e332f234d6d120bb82c444e4b05818b85103
|
||||
2026-09-10-derived-workspace-recency.zh.md: 87c2bb9f2648baa613ded32cda5b8673a77c5f56
|
||||
@@ -12,7 +12,7 @@ An editable activity-promoted list can disagree with its displayed timestamps wi
|
||||
|
||||
Last updated is a pure projection of current Session summaries: ordinary rows sort by descending `updatedAt`, with Session id as the tie-break. The selected blank New Session precedes ordinary rows until its first prompt. Grouped, Ungrouped, and flat views use the same policy. Reloads and delayed summaries require no observed-timestamp history or promotion events in the view store.
|
||||
|
||||
Only Manual uses browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. Reconciliation retains saved members that remain in the Workspace account, removes departed members, and appends newly known members at the end, newest first when several arrive together. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
|
||||
Manual reads browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. The [Session pinning and archive rules](../feature/2026-09-18-session-pin-and-sidebar-archive.md) own missing-member supplementation and keep archive visibility out of saved membership. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
|
||||
|
||||
The selected blank New Session is a display invariant applied after either base order: it remains first and cannot start a drag. Manual persists the observed blank position even while the Workspace stream reconnects, preserving every other saved member until the complete baseline permits reconciliation. Waiting to record that position would lose it if the first prompt arrives before the baseline. Its first prompt removes the blank state. Manual then keeps the existing first slot and allows dragging; Last updated projects it from the prompt timestamp. The ordering subscription and reconciliation remain mounted when the sidebar is collapsed or search replaces the list body.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
最近更新是当前 Session 摘要的纯投影:普通行按 `updatedAt` 降序排列,时间相同时按 Session id 排序。当前选中的空白新会话在首条提示词落地前排在普通行之前。分组、Ungrouped 和单列表视图采用相同策略。重新加载和摘要迟到不需要视图存储中的已观察时间历史或提升事件。
|
||||
|
||||
只有手动排序使用浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。对账会保留仍属于 Workspace 记账的已保存成员、移除已经离开的成员,并把新发现的成员追加到末尾;多条同时到达时按最近更新时间降序排列。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
|
||||
手动排序读取浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。[会话置顶与归档规则](../feature/2026-09-18-session-pin-and-sidebar-archive.zh.md)说明缺失成员补齐,并让归档显隐不影响保存的成员关系。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
|
||||
|
||||
当前选中的空白新会话是在两种基础顺序之后应用的显示不变式:它保持首位且无法发起拖拽。即使 Workspace 流正在重连,手动排序也会持久化已观察到的空白行位置,并保留其他所有已保存成员,直到完整基线允许对账。若等待基线后才记录该位置,首条提示词先到达时就会丢失它。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ interface CapturedFixture {
|
||||
readonly value: {
|
||||
readonly items: readonly WorkspaceView[]
|
||||
readonly archivedSessionIds: readonly string[]
|
||||
readonly pinnedSessionIds: readonly string[]
|
||||
}
|
||||
}
|
||||
readonly control: ControlBaseline
|
||||
@@ -186,6 +187,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
|
||||
value: {
|
||||
items: structuredClone(workspaces),
|
||||
archivedSessionIds: structuredClone(fixture.workspace.value.archivedSessionIds),
|
||||
pinnedSessionIds: structuredClone(fixture.workspace.value.pinnedSessionIds),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
+2
-1
@@ -253,7 +253,8 @@
|
||||
"updatedAt": "2026-09-13T03:02:55.884Z"
|
||||
}
|
||||
],
|
||||
"archivedSessionIds": []
|
||||
"archivedSessionIds": [],
|
||||
"pinnedSessionIds": []
|
||||
}
|
||||
},
|
||||
"control": {
|
||||
|
||||
@@ -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 docs/subsystems/web-client.md
|
||||
web-client.md: 59bf663864b367382aedb1352ee3ca38f3e651ba
|
||||
web-client.zh.md: f38ef5e2c9d2258be765d8a2bb400751d72948c8
|
||||
web-client.md: 6bcf9bf84ed0b049b801c38d2426074d784e8de6
|
||||
web-client.zh.md: 9c697964a0f92bc646f5ca305fc3372bebcdc902
|
||||
@@ -47,7 +47,7 @@ The durable event path opens `follow()`, whose first frame contains the current
|
||||
|
||||
### Workspaces
|
||||
|
||||
[`api/workspace-controller`](../../packages/api/workspace-controller/README.md) keeps Workspace mutation policy and the authoritative follow feed on the Host. `ClientWorkspaceModel` owns the browser rows, order, archived Session ids, command echoes, and stream/unary race resolution. Every stream generation starts with a complete baseline followed by `upsert`, `remove`, `order`, and `archived` increments; reconnect replaces the model from the new baseline. `WorkspaceController` exposes that model as `ctx.workspaces`, while `ui-workspace` contributes `useWorkspaces` and navigation callbacks to the UI. The archived Session ids filter every grouping surface and feed the archived-sessions Settings page, which joins the set with the loaded Session summaries and offers one Unarchive action per row; a restore calls the `workspace.unarchiveSession` Remote, and the complete returned set reaches every Client through the `archived` increment.
|
||||
[`api/workspace-controller`](../../packages/api/workspace-controller/README.md) keeps Workspace mutation policy and the authoritative follow feed on the Host. `ClientWorkspaceModel` owns browser rows, Workspace order, archived and pinned Session id arrays, command echoes, and stream/unary race resolution. Every stream generation starts with a complete baseline followed by `upsert`, `remove`, `order`, `archived`, and `pinned` increments; reconnect replaces the model from the new baseline. `WorkspaceController` exposes that model as `ctx.workspaces`, while `ui-workspace` contributes `useWorkspaces` and navigation callbacks. The sidebar's `ArchivedFilter` controls default-hidden, shown, or archived-only rows in lists and search. Archived rows retain their ordering slots, render grayed, and cannot open until restored through the row or search-result Unarchive action. Restoration calls `workspace.unarchiveSession`, and its complete archive set reaches Clients through the unary response and `archived` increment. Session display order stays browser-local and includes hidden archives; pinning moves a Session within that full order, while unpinning does not restore its earlier position.
|
||||
|
||||
This pairing is not a second source of business truth. Host controllers decide durable state and mutation outcomes; Client models maintain the latest usable local projection, preserve object identity where useful to rendering, and encode how delayed responses and replacement baselines merge.
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe
|
||||
|
||||
### Workspaces
|
||||
|
||||
[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace mutation policy 与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器侧 row、order、archived Session id、command echo,以及 stream/unary 竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order` 和 `archived` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,而 `ui-workspace` 向 UI 提供 `useWorkspaces` 与 navigation callback。archived Session id 过滤每一个分组视图,并驱动「已归档会话」设置页;该页把该集合与已加载的 Session summary 合并,为每行提供一个取消归档操作。恢复会调用 `workspace.unarchiveSession` Remote,返回的完整集合则经 `archived` increment 到达每个 Client。
|
||||
[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace 变更策略与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器行、Workspace 顺序、归档与置顶 Session id 数组、命令回显,以及流与一元调用的竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order`、`archived` 和 `pinned` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,`ui-workspace` 则提供 `useWorkspaces` 与导航回调。侧边栏的 `ArchivedFilter` 控制列表与搜索中的默认隐藏、显示已归档或仅显示已归档。归档行保留排序位置、置灰展示,必须通过行菜单或搜索结果的取消归档操作恢复后才能打开。恢复调用 `workspace.unarchiveSession`,完整归档集合通过一元响应及 `archived` increment 到达 Client。Session 显示顺序保存在浏览器本地,包含隐藏归档项;置顶在该完整顺序内移动 Session,取消置顶不恢复先前位置。
|
||||
|
||||
这种配对不会产生第二份业务真相。Host controller 决定持久状态与 mutation outcome;Client model 维护最新可用的本地 projection,在有利于渲染时保持 object identity,并明确 delayed response 与 replacement baseline 的合并规则。
|
||||
|
||||
|
||||
@@ -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 docs/subsystems/workspace.md
|
||||
workspace.md: 8ecf54a2860ab5d442c4856f6efd03900de1ce9b
|
||||
workspace.zh.md: dd4aed5f271aff0256115b03c85ec8b50ad0c502
|
||||
workspace.md: 9fe5f2bf4c62ba7d4f878b7b2f34490f43ca0b81
|
||||
workspace.zh.md: 63681e1a459c81a327654cfafa1f5cb2f231d6c8
|
||||
@@ -121,6 +121,10 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session
|
||||
|
||||
Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`.
|
||||
|
||||
## Session pinning
|
||||
|
||||
The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspacePinSessionRequest` and `WorkspaceUnpinSessionRequest`, each carrying one `sessionId`. Both operations return `WorkspacePinValue`: the complete `pinnedSessionIds` array of Session ids, most recently pinned first. Pinning requires a known, unarchived Session; unpinning an id that is not pinned succeeds without changing the set. Archiving removes the Session's pin in the same durable write, and unarchiving does not restore it.
|
||||
|
||||
## Consumers
|
||||
|
||||
[`dsh-workspace-controller`](../../packages/api/workspace-controller) serves workspace CRUD to GUI clients over `ctx.workspaceRegistry`, and [`dsh-session-controller`](../../packages/api/session-controller) performs the create-session-then-attach flow above. [dsh-agent-instructions](../../packages/context/agent-instructions) is **not** a consumer despite the name: it discovers AGENTS.md-style instruction files under an agent's own cwd and never touches `ctx.workspaceRegistry` — the shared word refers to the user's working directory, not to this registry's entities.
|
||||
@@ -339,6 +343,20 @@ Host service backing the generated `ctx.remote.workspace` namespace.
|
||||
*/
|
||||
@Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue>
|
||||
|
||||
/**
|
||||
* Surface one known unarchived Session ahead of unpinned Sessions.
|
||||
* @param request - Session identity to pin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('pinSession') pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue>
|
||||
|
||||
/**
|
||||
* Remove one Session's pin without changing its saved Session order.
|
||||
* @param request - Session identity to unpin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue>
|
||||
|
||||
/**
|
||||
* Stream a complete Workspace baseline followed by ordered increments.
|
||||
* @param signal - generation cancellation.
|
||||
@@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
|
||||
/**
|
||||
* Archive one session durably. The session must exist (live or in session
|
||||
* persistence); its workspace accounting — or lack of one — is irrelevant.
|
||||
* An already archived id resolves without writing.
|
||||
* Archiving drops the session's pin in the same durable write (pinning and
|
||||
* archival are mutually exclusive). An already archived id resolves without
|
||||
* writing.
|
||||
* @param sessionId - The session to archive.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
@@ -503,6 +523,25 @@ archiveSession(sessionId: SessionId): Promise<void>
|
||||
*/
|
||||
unarchiveSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Pin one session durably, prepending it to the registry-global pin set.
|
||||
* The session must exist (live or in session persistence) and must not be
|
||||
* archived. An already pinned id resolves without writing or reordering.
|
||||
* @param sessionId - The session to pin.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
pinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Unpin one session durably by dropping it from the registry-global pin
|
||||
* set. Unpinning runs no session-existence check because removing an id
|
||||
* cannot introduce an unknown one, so an entry whose session is gone still
|
||||
* resolves. An id that is not pinned resolves without writing.
|
||||
* @param sessionId - The session to unpin.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
unpinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
|
||||
@@ -121,6 +121,10 @@ interface Workspace {
|
||||
|
||||
会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。
|
||||
|
||||
## 会话置顶
|
||||
|
||||
控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspacePinSessionRequest` 和 `WorkspaceUnpinSessionRequest`,两者都携带一个 `sessionId`。两个操作都返回 `WorkspacePinValue`:完整的会话 id 数组 `pinnedSessionIds`,最近置顶的会话排在前面。置顶要求会话已知且未归档;对未置顶的 id 取消置顶会成功,且不改变集合。归档在同一次持久化写入中移除该会话的置顶,取消归档不会恢复置顶。
|
||||
|
||||
## 消费方
|
||||
|
||||
[`dsh-workspace-controller`](../../packages/api/workspace-controller) 经 `ctx.workspaceRegistry` 向 GUI 客户端提供工作区 CRUD,[`dsh-session-controller`](../../packages/api/session-controller) 执行上文「先建会话再 attach」的流程。[dsh-agent-instructions](../../packages/context/agent-instructions) 尽管名字如此,却**不是**消费方:它在 agent 自己的 cwd 下发现 AGENTS.md 风格的指令文件,从不触碰 `ctx.workspaceRegistry`——两者共用的这个词指的是用户的工作目录,而非本注册表的实体。
|
||||
@@ -339,6 +343,20 @@ Host service backing the generated `ctx.remote.workspace` namespace.
|
||||
*/
|
||||
@Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue>
|
||||
|
||||
/**
|
||||
* Surface one known unarchived Session ahead of unpinned Sessions.
|
||||
* @param request - Session identity to pin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('pinSession') pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue>
|
||||
|
||||
/**
|
||||
* Remove one Session's pin without changing its saved Session order.
|
||||
* @param request - Session identity to unpin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue>
|
||||
|
||||
/**
|
||||
* Stream a complete Workspace baseline followed by ordered increments.
|
||||
* @param signal - generation cancellation.
|
||||
@@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
|
||||
/**
|
||||
* Archive one session durably. The session must exist (live or in session
|
||||
* persistence); its workspace accounting — or lack of one — is irrelevant.
|
||||
* An already archived id resolves without writing.
|
||||
* Archiving drops the session's pin in the same durable write (pinning and
|
||||
* archival are mutually exclusive). An already archived id resolves without
|
||||
* writing.
|
||||
* @param sessionId - The session to archive.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
@@ -503,6 +523,25 @@ archiveSession(sessionId: SessionId): Promise<void>
|
||||
*/
|
||||
unarchiveSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Pin one session durably, prepending it to the registry-global pin set.
|
||||
* The session must exist (live or in session persistence) and must not be
|
||||
* archived. An already pinned id resolves without writing or reordering.
|
||||
* @param sessionId - The session to pin.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
pinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Unpin one session durably by dropping it from the registry-global pin
|
||||
* set. Unpinning runs no session-existence check because removing an id
|
||||
* cannot introduce an unknown one, so an entry whose session is gone still
|
||||
* resolves. An id that is not pinned resolves without writing.
|
||||
* @param sessionId - The session to unpin.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
unpinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
|
||||
@@ -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/api/workspace-controller/README.md
|
||||
README.md: ccf07bfb61ad005d516928ad27145148a62ddf19
|
||||
README.zh.md: b96f49e92696cef2475458f4b51c764063aa32f9
|
||||
README.md: 46556b0105bb5186cf27b56faee4127b358eac9b
|
||||
README.zh.md: 9d210c10486a71772cf6c0a10480b734fa992441
|
||||
@@ -22,9 +22,9 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable `workspace/*` or `directory-picker/*` code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected.
|
||||
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected.
|
||||
|
||||
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived Session ids, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner.
|
||||
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定 `workspace/*` 或 `directory-picker/*` 错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。
|
||||
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。
|
||||
|
||||
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、已归档 Session id、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。
|
||||
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export type {
|
||||
} from './model.ts'
|
||||
export { WorkspaceController, WorkspaceCreateError } from './service.ts'
|
||||
export type { IWorkspaces, WorkspaceSource } from './service.ts'
|
||||
export type { WorkspaceId, WorkspacePinnedSession, WorkspaceView } from '../types.ts'
|
||||
export type { WorkspaceId, WorkspaceView } from '../types.ts'
|
||||
|
||||
type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
|
||||
|
||||
@@ -108,7 +108,7 @@ function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncr
|
||||
accept.replaceArchived(frame.archivedSessionIds)
|
||||
return
|
||||
case 'pinned':
|
||||
accept.replacePinned(frame.pinnedSessions)
|
||||
accept.replacePinned(frame.pinnedSessionIds)
|
||||
return
|
||||
/* v8 ignore next -- the generated Remote codec validates this closed union */
|
||||
default:
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface WorkspaceSnapshot {
|
||||
/** Complete registry-global archive set in Host order. */
|
||||
readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']
|
||||
/** Complete registry-global pin set, most recently pinned first. */
|
||||
readonly pinnedSessions: WorkspacePinValue['pinnedSessions']
|
||||
readonly pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']
|
||||
readonly state: 'idle' | 'loading' | 'error'
|
||||
readonly phase: WorkspaceListPhase
|
||||
readonly error: RemoteFailure | null
|
||||
@@ -53,7 +53,7 @@ export interface WorkspaceFollowSink {
|
||||
/** Replace the complete archived Session set. */
|
||||
replaceArchived(sessionIds: WorkspaceArchiveValue['archivedSessionIds']): void
|
||||
/** Replace the complete pinned Session set. */
|
||||
replacePinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void
|
||||
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +62,7 @@ export interface WorkspaceFollowSink {
|
||||
export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
private items: readonly WorkspaceView[] = []
|
||||
private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = []
|
||||
private pinnedSessions: WorkspacePinValue['pinnedSessions'] = []
|
||||
private pinnedSessionIds: WorkspacePinValue['pinnedSessionIds'] = []
|
||||
private state: WorkspaceSnapshot['state'] = 'loading'
|
||||
private phase: WorkspaceListPhase = 'pending'
|
||||
private error: RemoteFailure | null = null
|
||||
@@ -185,7 +185,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
this.installArchived(result.value.archivedSessionIds)
|
||||
// The Host drops an archived session's pin in the same durable write;
|
||||
// mirror that locally so no frame shows the row both archived and pinned.
|
||||
this.installPinned(this.pinnedSessions.filter(entry => entry.sessionId !== sessionId))
|
||||
this.installPinned(this.pinnedSessionIds.filter(id => id !== sessionId))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
const requestSeq = ++this.pinRequestSeq
|
||||
const result = await this.remote.pinSession({ sessionId })
|
||||
if (result.ok && requestSeq === this.pinRequestSeq) {
|
||||
this.installPinned(result.value.pinnedSessions)
|
||||
this.installPinned(result.value.pinnedSessionIds)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
const requestSeq = ++this.pinRequestSeq
|
||||
const result = await this.remote.unpinSession({ sessionId })
|
||||
if (result.ok && requestSeq === this.pinRequestSeq) {
|
||||
this.installPinned(result.value.pinnedSessions)
|
||||
this.installPinned(result.value.pinnedSessionIds)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
this.pinRequestSeq++
|
||||
this.installViews(baseline.items)
|
||||
this.installArchived(baseline.archivedSessionIds)
|
||||
this.installPinned(baseline.pinnedSessions)
|
||||
this.installPinned(baseline.pinnedSessionIds)
|
||||
this.state = 'idle'
|
||||
this.phase = 'ready'
|
||||
this.error = null
|
||||
@@ -285,11 +285,11 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
|
||||
/**
|
||||
* Replace the pinned Session set from the current follow generation.
|
||||
* @param pinnedSessions - complete Host-confirmed pin set, most recently pinned first.
|
||||
* @param pinnedSessionIds - complete Host-confirmed pin set, most recently pinned first.
|
||||
*/
|
||||
replacePinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void {
|
||||
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void {
|
||||
this.pinRequestSeq++
|
||||
this.installPinned(pinnedSessions)
|
||||
this.installPinned(pinnedSessionIds)
|
||||
}
|
||||
|
||||
/** Keep the last complete projection visible while a lost carrier reconnects. */
|
||||
@@ -333,7 +333,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
return {
|
||||
items: this.items,
|
||||
archivedSessionIds: this.archivedSessionIds,
|
||||
pinnedSessions: this.pinnedSessions,
|
||||
pinnedSessionIds: this.pinnedSessionIds,
|
||||
state: this.state,
|
||||
phase: this.phase,
|
||||
error: this.error,
|
||||
@@ -347,11 +347,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private installPinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void {
|
||||
if (pinnedSessions.length === this.pinnedSessions.length
|
||||
&& pinnedSessions.every((entry, index) => entry.sessionId === this.pinnedSessions[index]?.sessionId
|
||||
&& entry.pinnedAt === this.pinnedSessions[index]?.pinnedAt)) return
|
||||
this.pinnedSessions = [...pinnedSessions]
|
||||
private installPinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void {
|
||||
if (pinnedSessionIds.length === this.pinnedSessionIds.length
|
||||
&& pinnedSessionIds.every((id, index) => id === this.pinnedSessionIds[index])) return
|
||||
this.pinnedSessionIds = [...pinnedSessionIds]
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface IWorkspaces {
|
||||
*/
|
||||
pinSession(sessionId: SessionId): Promise<void>
|
||||
/**
|
||||
* Unpin a Session back to its natural list position.
|
||||
* Remove a Session's pin without changing its saved Session order.
|
||||
* @param sessionId - Session to unpin.
|
||||
*/
|
||||
unpinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
WorkspaceUnknownSessionError,
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { pinnedSessionViews, workspaceView } from './feed.ts'
|
||||
import { workspaceView } from './feed.ts'
|
||||
import type {
|
||||
WorkspaceArchiveSessionRequest,
|
||||
WorkspaceArchiveValue,
|
||||
@@ -194,7 +194,7 @@ export class WorkspaceCommands {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) }
|
||||
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,7 +206,7 @@ export class WorkspaceCommands {
|
||||
*/
|
||||
async unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue> {
|
||||
await this.ctx.workspaceRegistry.unpinSession(request.sessionId)
|
||||
return { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) }
|
||||
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
|
||||
}
|
||||
|
||||
private requireWorkspace(workspaceId: WorkspaceId): Workspace {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
|
||||
import type { PinnedSession, Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState,
|
||||
workspaceRecord,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import type {
|
||||
WorkspaceBaseline,
|
||||
WorkspaceFollowFrame,
|
||||
WorkspacePinnedSession,
|
||||
WorkspaceView,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -44,19 +43,6 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project registry pin entries into their Remote values.
|
||||
* @param entries - authoritative registry pin entries.
|
||||
* @returns detached pin projections for Remote consumers.
|
||||
*/
|
||||
export function pinnedSessionViews(entries: readonly PinnedSession[]): WorkspacePinnedSession[] {
|
||||
return entries.map(entry => ({ sessionId: entry.id, pinnedAt: entry.pinnedAt }))
|
||||
}
|
||||
|
||||
/** One comparable key per pin entry; pinnedAt participates so a re-pin at a new instant publishes. */
|
||||
const pinKeys = (entries: readonly PinnedSession[]): string[] =>
|
||||
entries.map(entry => `${entry.id}@${entry.pinnedAt}`)
|
||||
|
||||
/** Owns Workspace domain observation and all active follow generations. */
|
||||
export class WorkspaceFeed {
|
||||
private readonly followers = new Set<WorkspaceFollower>()
|
||||
@@ -71,7 +57,7 @@ export class WorkspaceFeed {
|
||||
this.knownIds = new Set(baseline.map(workspace => String(workspace.id)))
|
||||
this.order = baseline.map(workspace => String(workspace.id))
|
||||
this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String)
|
||||
this.pinned = pinKeys(ctx.workspaceRegistry.pinnedSessions)
|
||||
this.pinned = ctx.workspaceRegistry.pinnedSessionIds.map(String)
|
||||
ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) })
|
||||
ctx.effect(() => () => {
|
||||
for (const follower of this.followers) follower.close()
|
||||
@@ -87,7 +73,7 @@ export class WorkspaceFeed {
|
||||
return {
|
||||
items: this.ctx.workspaceRegistry.list().map(workspaceView),
|
||||
archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds],
|
||||
pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions),
|
||||
pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,10 +118,10 @@ export class WorkspaceFeed {
|
||||
this.archived = nextArchived
|
||||
this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] })
|
||||
}
|
||||
const nextPinned = pinKeys(state.pinnedSessions)
|
||||
const nextPinned = state.pinnedSessionIds.map(String)
|
||||
if (!sameStrings(this.pinned, nextPinned)) {
|
||||
this.pinned = nextPinned
|
||||
this.publish({ type: 'pinned', pinnedSessions: pinnedSessionViews(state.pinnedSessions) })
|
||||
this.publish({ type: 'pinned', pinnedSessionIds: [...state.pinnedSessionIds] })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class WorkspaceController extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one pinned Session to its natural list position.
|
||||
* Remove one Session's pin without changing its saved Session order.
|
||||
* @param request - Session identity to unpin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
|
||||
@@ -119,20 +119,14 @@ export interface WorkspacePinSessionRequest {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Session requested for unpinning back to its natural list position. */
|
||||
/** Session requested for removal from the pin set. */
|
||||
export interface WorkspaceUnpinSessionRequest {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** One pinned Session entry: the Session and the epoch-millisecond instant it was pinned at. */
|
||||
export interface WorkspacePinnedSession {
|
||||
readonly sessionId: SessionId
|
||||
readonly pinnedAt: number
|
||||
}
|
||||
|
||||
/** Complete pinned Session set after a mutation, most recently pinned first. */
|
||||
export interface WorkspacePinValue {
|
||||
readonly pinnedSessions: readonly WorkspacePinnedSession[]
|
||||
readonly pinnedSessionIds: readonly SessionId[]
|
||||
}
|
||||
|
||||
/** Complete reconnect baseline for Workspace browser state. */
|
||||
@@ -140,7 +134,7 @@ export interface WorkspaceBaseline {
|
||||
readonly items: readonly WorkspaceView[]
|
||||
readonly archivedSessionIds: readonly SessionId[]
|
||||
/** Registry-global pin set, most recently pinned first. */
|
||||
readonly pinnedSessions: readonly WorkspacePinnedSession[]
|
||||
readonly pinnedSessionIds: readonly SessionId[]
|
||||
}
|
||||
|
||||
/** One ordered Workspace change after a generation's baseline. */
|
||||
@@ -149,7 +143,7 @@ export type WorkspaceFollowIncrement =
|
||||
| { readonly type: 'remove'; readonly workspaceId: WorkspaceId }
|
||||
| { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] }
|
||||
| { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] }
|
||||
| { readonly type: 'pinned'; readonly pinnedSessions: readonly WorkspacePinnedSession[] }
|
||||
| { readonly type: 'pinned'; readonly pinnedSessionIds: readonly SessionId[] }
|
||||
|
||||
/** Workspace state stream; every generation starts with exactly one baseline. */
|
||||
export type WorkspaceFollowFrame =
|
||||
|
||||
@@ -20,7 +20,6 @@ import type {
|
||||
WorkspaceUnpinSessionRequest,
|
||||
WorkspaceValue,
|
||||
WorkspaceId,
|
||||
WorkspacePinnedSession,
|
||||
WorkspaceView,
|
||||
} from '../src/types.ts'
|
||||
import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
@@ -28,9 +27,6 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
const wid = (id: string): WorkspaceId => id as WorkspaceId
|
||||
const pin = (id: string, pinnedAt = 1): WorkspacePinnedSession => ({ sessionId: sid(id), pinnedAt })
|
||||
const pinnedIds = (entries: readonly WorkspacePinnedSession[]): string[] =>
|
||||
entries.map(entry => String(entry.sessionId))
|
||||
|
||||
function workspace(
|
||||
id: string,
|
||||
@@ -99,11 +95,11 @@ class FakeWorkspaceRemote implements WorkspaceRemote {
|
||||
onPinSession: (
|
||||
request: WorkspacePinSessionRequest,
|
||||
) => Promise<RemoteResult<WorkspacePinValue>> = request =>
|
||||
Promise.resolve(remoteOk({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] }))
|
||||
Promise.resolve(remoteOk({ pinnedSessionIds: [request.sessionId] }))
|
||||
onUnpinSession: (
|
||||
_request: WorkspaceUnpinSessionRequest,
|
||||
) => Promise<RemoteResult<WorkspacePinValue>> = () =>
|
||||
Promise.resolve(remoteOk({ pinnedSessions: [] }))
|
||||
Promise.resolve(remoteOk({ pinnedSessionIds: [] }))
|
||||
|
||||
create(request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
|
||||
this.record('create', request)
|
||||
@@ -165,9 +161,9 @@ function baseline(
|
||||
model: ClientWorkspaceModel,
|
||||
items: readonly WorkspaceView[] = [],
|
||||
archivedSessionIds: readonly SessionId[] = [],
|
||||
pinnedSessions: readonly WorkspacePinnedSession[] = [],
|
||||
pinnedSessionIds: readonly SessionId[] = [],
|
||||
): void {
|
||||
model.replaceBaseline({ items, archivedSessionIds, pinnedSessions })
|
||||
model.replaceBaseline({ items, archivedSessionIds, pinnedSessionIds })
|
||||
}
|
||||
|
||||
describe('ClientWorkspaceModel', () => {
|
||||
@@ -422,38 +418,36 @@ describe('ClientWorkspaceModel', () => {
|
||||
it('applies pin mutation echoes and leaves failed results unchanged', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
baseline(model, [], [], [pin('kept')])
|
||||
baseline(model, [], [], [sid('kept')])
|
||||
|
||||
remote.onPinSession = () => Promise.resolve(workspaceError(
|
||||
new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }),
|
||||
))
|
||||
await expect(model.pinSession(sid('missing'))).resolves.toMatchObject({ ok: false })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
|
||||
remote.onPinSession = request => Promise.resolve(remoteOk({
|
||||
pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 2 }, pin('kept')],
|
||||
pinnedSessionIds: [request.sessionId, sid('kept')],
|
||||
}))
|
||||
await expect(model.pinSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['fresh', 'kept'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['fresh', 'kept'])
|
||||
expect(remote.calls).toContainEqual({ method: 'pinSession', request: { sessionId: 'fresh' } })
|
||||
|
||||
remote.onUnpinSession = () => Promise.resolve(workspaceError(
|
||||
new RemoteError('gateway/internal', 'wire down', {}),
|
||||
))
|
||||
await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: false })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['fresh', 'kept'])
|
||||
remote.onUnpinSession = () => Promise.resolve(remoteOk({ pinnedSessions: [pin('kept')] }))
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['fresh', 'kept'])
|
||||
remote.onUnpinSession = () => Promise.resolve(remoteOk({ pinnedSessionIds: [sid('kept')] }))
|
||||
await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
|
||||
expect(remote.calls).toContainEqual({ method: 'unpinSession', request: { sessionId: 'fresh' } })
|
||||
|
||||
// A pushed set identical to the local one publishes no new snapshot; the
|
||||
// same member re-pinned at a new instant does.
|
||||
const before = model.getSnapshot()
|
||||
model.replacePinned([pin('kept')])
|
||||
model.replacePinned([sid('kept')])
|
||||
expect(model.getSnapshot()).toBe(before)
|
||||
model.replacePinned([pin('kept', 9)])
|
||||
model.replacePinned([sid('other')])
|
||||
expect(model.getSnapshot()).not.toBe(before)
|
||||
expect(model.getSnapshot().pinnedSessions).toEqual([pin('kept', 9)])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual([sid('other')])
|
||||
})
|
||||
|
||||
it('keeps the latest pin reply when overlapping requests settle out of order', async () => {
|
||||
@@ -466,36 +460,36 @@ describe('ClientWorkspaceModel', () => {
|
||||
|
||||
const first = model.pinSession(sid('first'))
|
||||
const second = model.pinSession(sid('second'))
|
||||
secondGate.resolve(remoteOk({ pinnedSessions: [pin('second', 2), pin('first')] }))
|
||||
secondGate.resolve(remoteOk({ pinnedSessionIds: [sid('second'), sid('first')] }))
|
||||
await expect(second).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['second', 'first'])
|
||||
firstGate.resolve(remoteOk({ pinnedSessions: [pin('first')] }))
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['second', 'first'])
|
||||
firstGate.resolve(remoteOk({ pinnedSessionIds: [sid('first')] }))
|
||||
await expect(first).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['second', 'first'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('keeps a pushed pin set when an unpin reply lands later', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
baseline(model, [], [], [pin('first')])
|
||||
baseline(model, [], [], [sid('first')])
|
||||
const gate = deferred<RemoteResult<WorkspacePinValue>>()
|
||||
remote.onUnpinSession = () => gate.promise
|
||||
|
||||
const pending = model.unpinSession(sid('first'))
|
||||
model.replacePinned([pin('first'), pin('second', 2)])
|
||||
gate.resolve(remoteOk({ pinnedSessions: [] }))
|
||||
model.replacePinned([sid('first'), sid('second')])
|
||||
gate.resolve(remoteOk({ pinnedSessionIds: [] }))
|
||||
await expect(pending).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['first', 'second'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('mirrors the Host pin drop locally when an archive echo lands', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
baseline(model, [], [], [pin('pinned'), pin('kept')])
|
||||
baseline(model, [], [], [sid('pinned'), sid('kept')])
|
||||
|
||||
await expect(model.archiveSession(sid('pinned'))).resolves.toMatchObject({ ok: true })
|
||||
expect(model.getSnapshot().archivedSessionIds).toEqual(['pinned'])
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('keeps the newest row and places Workspaces missing from partial orders last', async () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export function workspace(id: string, overrides: Partial<WorkspaceView> = {}): W
|
||||
export function baseline(...ids: readonly string[]): WorkspaceBaselineFrame {
|
||||
return {
|
||||
type: 'baseline',
|
||||
value: { items: ids.map(id => workspace(id)), archivedSessionIds: [], pinnedSessions: [] },
|
||||
value: { items: ids.map(id => workspace(id)), archivedSessionIds: [], pinnedSessionIds: [] },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export const workspaceWorld: RemoteTable = {
|
||||
}),
|
||||
'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [request.sessionId] }),
|
||||
'workspace/unarchiveSession': (_request: WorkspaceUnarchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [] }),
|
||||
'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] }),
|
||||
'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessions: [] }),
|
||||
'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessionIds: [request.sessionId] }),
|
||||
'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessionIds: [] }),
|
||||
},
|
||||
}
|
||||
@@ -154,7 +154,7 @@ describe('Workspace state stream', () => {
|
||||
{ type: 'remove', workspaceId: view.workspaceId },
|
||||
{ type: 'order', workspaceIds: [view.workspaceId] },
|
||||
{ type: 'archived', archivedSessionIds: [sid('session-one')] },
|
||||
{ type: 'pinned', pinnedSessions: [{ sessionId: sid('session-two'), pinnedAt: 1 }] },
|
||||
{ type: 'pinned', pinnedSessionIds: [sid('session-two')] },
|
||||
]
|
||||
mock.stream(FOLLOW, openStream([opening, ...increments]))
|
||||
const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
|
||||
@@ -177,7 +177,7 @@ describe('Workspace state stream', () => {
|
||||
expect(removeView).toHaveBeenCalledWith(view.workspaceId)
|
||||
expect(replaceOrder).toHaveBeenCalledWith([view.workspaceId])
|
||||
expect(replaceArchived).toHaveBeenCalledWith(['session-one'])
|
||||
expect(replacePinned).toHaveBeenCalledWith([{ sessionId: 'session-two', pinnedAt: 1 }])
|
||||
expect(replacePinned).toHaveBeenCalledWith(['session-two'])
|
||||
await stream.dispose()
|
||||
expect(streamStates(mock)).toEqual(['cancelled'])
|
||||
})
|
||||
@@ -313,7 +313,7 @@ describe('WorkspaceController', () => {
|
||||
it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => {
|
||||
const { remote, client } = await gatewayClient(mock, start)
|
||||
const model = new ClientWorkspaceModel(remote.workspace)
|
||||
model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [], pinnedSessions: [] })
|
||||
model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [], pinnedSessionIds: [] })
|
||||
const controller = new WorkspaceController(client.ctx, model)
|
||||
|
||||
expect(controller.list).toBe(model)
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('WorkspaceController commands', () => {
|
||||
})
|
||||
|
||||
await expect(controller.pinSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ pinnedSessions: [{ sessionId: session.id, pinnedAt: expect.any(Number) }] })
|
||||
.resolves.toEqual({ pinnedSessionIds: [session.id] })
|
||||
await expect(controller.pinSession({ sessionId: SessionId('unknown') }))
|
||||
.rejects.toMatchObject({ code: 'session/not-found' })
|
||||
|
||||
@@ -264,10 +264,10 @@ describe('WorkspaceController commands', () => {
|
||||
.rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
|
||||
await expect(controller.unpinSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ pinnedSessions: [] })
|
||||
.resolves.toEqual({ pinnedSessionIds: [] })
|
||||
// Unpin is idempotent: an id that is not pinned is not an error.
|
||||
await expect(controller.unpinSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ pinnedSessions: [] })
|
||||
.resolves.toEqual({ pinnedSessionIds: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -290,19 +290,38 @@ describe('WorkspaceController follow', () => {
|
||||
initialized: true,
|
||||
workspaceIds: ['missing'],
|
||||
archivedSessionIds: [],
|
||||
pinnedSessions: [],
|
||||
pinnedSessionIds: [],
|
||||
},
|
||||
})
|
||||
}).toThrow('references missing Workspace "missing"')
|
||||
})
|
||||
|
||||
it('starts a fresh feed with existing pins and follows their removal', async () => {
|
||||
const { controller, ctx, root } = await harness()
|
||||
const session = ctx.sessions.create(SessionId('already-pinned'), { meta: { cwd: root } })
|
||||
await controller.pinSession({ sessionId: session.id })
|
||||
const feed = new WorkspaceFeed(ctx)
|
||||
const abort = new AbortController()
|
||||
const iterator = feed.follow(abort.signal)[Symbol.asyncIterator]()
|
||||
try {
|
||||
await expect(nextFrame(iterator)).resolves.toMatchObject({
|
||||
type: 'baseline', value: { pinnedSessionIds: [session.id] },
|
||||
})
|
||||
await controller.unpinSession({ sessionId: session.id })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [] })
|
||||
} finally {
|
||||
abort.abort()
|
||||
await iterator.return?.()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts with a complete baseline and emits committed increments in domain order', async () => {
|
||||
const { controller, ctx, root } = await harness()
|
||||
const abort = new AbortController()
|
||||
const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]()
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'baseline',
|
||||
value: { items: [], archivedSessionIds: [], pinnedSessions: [] },
|
||||
value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] },
|
||||
})
|
||||
|
||||
const first = await controller.create({ path: stageDir(root, 'first') })
|
||||
@@ -347,12 +366,12 @@ describe('WorkspaceController follow', () => {
|
||||
})
|
||||
await controller.pinSession({ sessionId: session.id })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'pinned', pinnedSessions: [{ sessionId: session.id, pinnedAt: expect.any(Number) }],
|
||||
type: 'pinned', pinnedSessionIds: [session.id],
|
||||
})
|
||||
// Unpin rides the same complete-set increment: no new frame type.
|
||||
await controller.unpinSession({ sessionId: session.id })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'pinned', pinnedSessions: [],
|
||||
type: 'pinned', pinnedSessionIds: [],
|
||||
})
|
||||
await controller.delete({ workspaceId: second.workspace.workspaceId })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
|
||||
@@ -25,7 +25,7 @@ function emptySessions() {
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ function emptySessions() {
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ function emptySessions() {
|
||||
|
||||
function emptyWorkspaces() {
|
||||
return bindSnapshotSelector(createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function emptySessions() {
|
||||
|
||||
function emptyWorkspaces() {
|
||||
return bindSnapshotSelector(createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ function bench(over?: BenchOptions) {
|
||||
subagentsByParent: {}, jobsBySession: {},
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})),
|
||||
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
|
||||
(selector ?? (v => v))(key === 'plan'
|
||||
|
||||
@@ -62,7 +62,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useSessionRetainInfo: () => undefined,
|
||||
useResource,
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
|
||||
@@ -156,7 +156,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
|
||||
useSessionRetainInfo: () => undefined,
|
||||
useResource,
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: (() => undefined),
|
||||
|
||||
@@ -111,7 +111,7 @@ function workspace(id = 'w1'): WorkspaceView {
|
||||
}
|
||||
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({
|
||||
items, archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items, archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
|
||||
function sessionSnapshotOf(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
||||
|
||||
@@ -81,7 +81,7 @@ function mountFrame(windowWidth = frameWidth) {
|
||||
jobsBySession: {},
|
||||
})
|
||||
const workspaceState: WorkspaceSnapshot = {
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }),
|
||||
}
|
||||
const useStore = bindSnapshotSelector(instance)
|
||||
|
||||
@@ -31,7 +31,7 @@ function emptySessions() {
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function emptySessions() {
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ function emptySessions() {
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ const attentionState: AttentionState = new Map()
|
||||
const workspaceState = {
|
||||
items: [],
|
||||
archivedSessionIds: [],
|
||||
pinnedSessions: [],
|
||||
pinnedSessionIds: [],
|
||||
state: 'idle' as const,
|
||||
phase: 'ready' as const,
|
||||
error: null,
|
||||
|
||||
@@ -57,7 +57,7 @@ const attentionState: AttentionState = new Map()
|
||||
const workspaceState = {
|
||||
items: [],
|
||||
archivedSessionIds: [],
|
||||
pinnedSessions: [],
|
||||
pinnedSessionIds: [],
|
||||
state: 'idle' as const,
|
||||
phase: 'ready' as const,
|
||||
error: null,
|
||||
|
||||
@@ -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/ui-workspace/README.md
|
||||
README.md: a14dcd70ed47467e29619915921dcd25314d84ce
|
||||
README.zh.md: 4886d8c546d8102e3be9883ed27ba9913e4aca81
|
||||
README.md: 2f5ccee44e9d69eec45cf23bb8c368d67974eda7
|
||||
README.zh.md: 173129d6d03ccd6d3ccf9a4f72effae612711bcb
|
||||
@@ -29,7 +29,11 @@ Use the sidebar to browse Workspaces and their Sessions, reorder them, and start
|
||||
|
||||
### Reordering and view options
|
||||
|
||||
**Last updated** orders ordinary Sessions by their latest user prompt or steer time, newest first, in both grouped and flat views. **Manual** freezes the current displayed order and holds positions when activity changes; newly discovered ordinary Sessions append to the end, newest first when several arrive together. Returning to Last updated discards every manual position, and entering Manual again freezes the then-current recency order. The browser defaults to Last updated and remembers the selected mode across reloads. Dragging an ordinary Session applies the move locally and selects Manual. The selected blank **New Session** is always pinned first and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining its first position in Manual or following its current timestamp in Last updated. In a collapsed group, drag boundaries follow rendered rows and place the source before intervening hidden rows, so a drag cannot hide its source. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable.
|
||||
Pinned Sessions lead ordinary Sessions in both grouped and flat views. **Last updated** sorts each partition strictly by the latest user prompt or steer time, newest first; pin time does not affect it. **Manual** uses the relative positions in one complete Session sequence, including hidden archives. Returning to Last updated discards the manual layout, and entering Manual again freezes the then-current chronological order. The browser defaults to Last updated and remembers the selected mode across reloads.
|
||||
|
||||
Pinning moves the Session to the front of its complete saved sequence without changing the selected mode. It leads the pinned partition in Manual but need not lead it in Last updated. Unpinning leaves the saved position unchanged. Dragging either a pinned or ordinary row edits that same complete sequence and selects Manual; hidden archived members are retained. Missing pinned members prepend in pin-array order, while other missing members append by recency with missing archives last. Supplementation stays in memory until a Session-order write records the complete result. Archive filtering alone changes neither saved positions nor account membership.
|
||||
|
||||
The selected blank **New Session** retains its provisional first slot and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining that position in Manual or following its current timestamp in Last updated. A collapsed-group drag uses the target Session identity and keeps the source visible. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable. The [Session pinning and archive decision](../../../.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md) records the ordering and recovery rules.
|
||||
|
||||
### Workspace hierarchy
|
||||
|
||||
@@ -39,11 +43,13 @@ Hierarchy uses registered canonical paths only. It does not scan for projects or
|
||||
|
||||
### Search
|
||||
|
||||
Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20. Choosing a result clears and collapses search, opens the Session, and scrolls its row into view in the configured browsing mode; grouped browsing also expands its Workspace and the full Session list when required.
|
||||
Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible without an additional warning. The list is capped at 20. Choosing an unarchived result clears and collapses search, opens the Session, and scrolls its row into view in the configured browsing mode; grouped browsing also expands its Workspace and the full Session list when required. Archived results offer Unarchive; attempting to open one explains that restriction without clearing the query or navigating.
|
||||
|
||||
### Managing sessions
|
||||
|
||||
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Rename uses a temporary `workspaceOperation` reference, while fork-title assignment uses a temporary `controllerOperation` reference inside Session Controller; both await the reference's initial history opening. Archive commits without a confirmation dialog and the row disappears from every grouping surface when the archive-set echo lands. Fork forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped.
|
||||
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference, while fork-title assignment uses a temporary `controllerOperation` reference inside Session Controller; both await the reference's initial history opening. Fork forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped.
|
||||
|
||||
Archive commits without a confirmation dialog and retains the Session's account position. View options control visibility: the default hides archived Sessions, Show archived includes them, and Archived only hides ordinary Sessions. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a toast with undo and filter-menu actions. Unarchive removes the archive mark without restoring a pin or changing the saved position.
|
||||
|
||||
A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start.
|
||||
|
||||
@@ -77,7 +83,9 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv
|
||||
|
||||
### View state
|
||||
|
||||
Once the Workspace list baseline is ready, browser-persisted expansion and manual Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions require the current account orders explicitly. Flat-list membership and ordering use Session ids; row rendering adds status indicators once. Entering Manual snapshots every active account from the current display; reconciliation retains saved members that still belong to the account, removes departed members, and appends newly known members by recency. A new membership entry without a Session summary is omitted until that summary arrives, while an already saved slot survives a temporarily missing summary. During Workspace reconnection, Manual records an observed blank Session at the front of its saved flat and known group orders without removing other saved members; full membership reconciliation waits for the Workspace baseline. This reconciliation remains mounted while the sidebar is a rail or search replaces its body. Last updated derives directly from each current list snapshot without reading or writing saved positions; equal timestamps use Session ids as a stable tie-break. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`, and each visible ordinary row inherits the shared ongoing loader while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`.
|
||||
Once the Workspace baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions receive complete account orders, never filtered rows. A new member without a Session summary waits for that summary, while a saved position survives a temporarily missing summary. Archive visibility is applied only when deriving rows. Pin and drag writes save complete orders; ordinary derivation does not write them. The selected blank Session remains an explicit position write, including during Workspace reconnection, when other saved members are retained until the baseline establishes membership. Ordering remains mounted while the sidebar is a rail or search replaces its body. Last updated derives from current summaries without reading saved positions; equal timestamps use Session ids as a stable tie-break.
|
||||
|
||||
The sidebar hides durable summaries with `origin: 'subagent'`. Each visible ordinary row inherits the shared ongoing loader while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`.
|
||||
|
||||
Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measures keyed rows before and after React commits that change their membership or order, and uses native movement and opacity animations. Removed rows fade as inert copies outside the scrolling list; they do not delay React unmounting or extend its scroll range. Initial loading, drag commits, overflow expansion, and view-option changes settle immediately. The animator has no layout observers or polling and does not measure content-only updates or scrolling.
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ kind: "package-reference"
|
||||
|
||||
### 重排序与视图选项
|
||||
|
||||
**最近更新**在分组和单列表视图中都按普通 Session 的最近一次用户提示词或 steering(中途引导)时间降序排列。**手动排序**会冻结当前显示顺序,并在活动变化时保持已有位置;新发现的普通 Session 追加到末尾,多条同时到达时按最近更新时间降序排列。返回最近更新会丢弃所有手动位置;再次进入手动排序时,则冻结当时的最近更新顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。拖拽普通 Session 会在浏览器本地应用移动并选中手动排序。当前选中的空白**新会话**始终固定在首位且无法拖拽;首条提示词落地后,它成为可拖拽的普通行,在手动排序中保留首位,在最近更新中按当前时间戳排列。折叠分组的拖拽边界按渲染行确定,并把来源行放在中间隐藏行之前,因此拖拽不会隐藏来源行。真实 Workspace、Ungrouped 与单列表的 Session 显示顺序都保留在浏览器本地;Workspace 分组的拖拽顺序仍由 Host 持久化。
|
||||
分组和平铺视图都先显示置顶 Session,再显示普通 Session。**最近更新**在各分区内严格按最近一次用户提示词或 steering(中途引导)时间降序排列,置顶时间不影响它。**手动排序**使用同一条完整 Session 序列中的相对位置,包括隐藏的归档项。返回最近更新会丢弃手动布局,再次进入手动排序时冻结当时的时间顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。
|
||||
|
||||
置顶把 Session 移到完整保存序列的首位,但不切换所选模式。它在手动模式下排在置顶区最前,在最近更新模式下则不一定。取消置顶不改变保存的位置。拖拽置顶行或普通行都修改同一条完整序列并选择手动模式,隐藏的归档成员始终保留。缺失的置顶成员按置顶数组顺序补到头部,其他缺失成员按最近更新时间追加,缺失归档项放在最后。补齐仅在内存中完成,直到一次 Session 顺序写入保存完整结果。单纯切换归档筛选既不改变保存的位置,也不改变成员关系。
|
||||
|
||||
当前选中的空白**新会话**保留临时首位且无法拖拽;首条提示词落地后,它成为可拖拽的普通行,在手动排序中保留该位置,在最近更新中按当前时间戳排列。折叠分组的拖拽使用目标 Session 身份,并保持来源行可见。真实 Workspace、Ungrouped 与单列表的 Session 显示顺序都保留在浏览器本地;Workspace 分组的拖拽顺序仍由 Host 持久化。[会话置顶与归档决定](../../../.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md)记录排序与恢复规则。
|
||||
|
||||
### 工作区层级
|
||||
|
||||
@@ -39,11 +43,13 @@ kind: "package-reference"
|
||||
|
||||
### 搜索
|
||||
|
||||
折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果。选择结果会清空并收起搜索、打开 Session,并在当前浏览模式中将其行滚动到可见区域;分组浏览还会按需展开所属 Workspace 和完整 Session 列表。
|
||||
折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,不另给警告。列表最多显示 20 条结果。选择未归档结果会清空并收起搜索、打开 Session,并在当前浏览模式中将其行滚动到可见区域;分组浏览还会按需展开所属 Workspace 和完整 Session 列表。已归档结果提供取消归档操作;尝试打开时会说明限制,不清空查询,也不导航。
|
||||
|
||||
### 管理会话
|
||||
|
||||
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Rename 使用临时 `workspaceOperation` reference,fork 标题设置则由 Session Controller 使用临时 `controllerOperation` reference;两者都等待该 reference 的首次历史打开。Archive 不经确认对话框直接提交,归档集合回声落地后,该行从所有分组视图中消失。Fork 在源会话最后一个已完成轮次处 fork,在客户端递增继承的持久化标题后再打开子会话。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。
|
||||
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,fork 标题设置则由 Session Controller 使用临时 `controllerOperation` reference;两者都等待该 reference 的首次历史打开。Fork 在源会话最后一个已完成轮次处 fork,在客户端递增继承的持久化标题后再打开子会话。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。
|
||||
|
||||
Archive 不经确认对话框直接提交,并保留 Session 的记账位置。视图选项控制显隐:默认隐藏已归档 Session,显示已归档会将其纳入列表,仅显示已归档则隐藏普通 Session。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后,toast 提供撤销与打开筛选菜单的操作。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。
|
||||
|
||||
标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。
|
||||
|
||||
@@ -77,7 +83,9 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
|
||||
|
||||
### 视图状态
|
||||
|
||||
Workspace 列表基线就绪后,浏览器持久化的展开状态和手动 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作要求显式传入当前各记账的顺序。单列表的成员筛选和排序使用 Session id,行渲染只计算一次状态指示。进入手动排序会从当前显示结果一次性记录每个有效记账;对账会保留仍属于该记账的已保存成员、移除已经离开的成员,并按最近更新时间追加新发现的成员。尚无 Session 摘要的新成员会等摘要到达后再加入,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 重连期间,手动排序会将已观察到的空白 Session 记录到已保存的单列表及已知分组顺序首位,不移除其他已保存成员;完整成员对账等待 Workspace 基线到齐。即使侧边栏收成窄栏或搜索替代列表主体,这项对账也保持挂载。最近更新直接从每份当前列表快照派生,不读取或写入已保存位置;时间相同时按 Session id 稳定排序。共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承共享 ongoing loading。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。
|
||||
Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作接收完整记账顺序,而不是筛选后的行。尚无 Session 摘要的新成员会等待摘要,已保存的位置则在摘要暂时缺失时保留。归档显隐仅在派生行时应用。置顶和拖拽写入完整顺序,普通派生不执行写入。当前选中的空白 Session 仍是一次显式位置写入;Workspace 重连时同样如此,此时保留其他已保存成员,直到基线确定成员关系。侧边栏收成窄栏或搜索替代列表主体时,排序仍保持挂载。最近更新从当前摘要派生,不读取已保存位置;时间相同时按 Session id 稳定排序。
|
||||
|
||||
侧边栏隐藏持久化摘要中带有 `origin: 'subagent'` 的行。每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承共享 ongoing loading。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。
|
||||
|
||||
行动画由 [AnimatedRows](src/client/rows/AnimatedRows.tsx) 负责。它仅在 React 提交改变行成员或顺序时读取更新前后的位置,并使用浏览器原生位移与透明度动画。被移除的行以不可交互的副本在滚动列表外淡出,不会延迟 React 卸载,也不会扩大列表的滚动范围。初始加载、拖拽提交、展开其余会话和视图选项变化直接完成。动画组件不使用布局观察器或轮询,也不会因仅内容更新或滚动而测量位置。
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||
* time->ellipsis, action buttons) are CSS-only, and a session row's clipped
|
||||
* title is scrolled programmatically while the row is hovered. Row ... menus are
|
||||
* visual-only except workspace Rename/Delete and session Rename/Fork/Archive; the
|
||||
* session and workspace hover cards are suppressed while a menu is open.
|
||||
* title is scrolled programmatically while the row is hovered. Session and
|
||||
* Workspace hover cards are suppressed while a menu is open.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -412,6 +411,7 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }:
|
||||
className={clsx(css.searchResultRow, selected && css.selected, result.archived && css.archived)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
aria-description={result.archived ? t('toast.archivedNotOpenable') : undefined}
|
||||
onClick={() => { onOpen(result.id) }}
|
||||
>
|
||||
<span className={css.searchResultHeading}>
|
||||
@@ -542,6 +542,7 @@ export function SessionNodeItem({
|
||||
)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
aria-description={row.archived ? t('toast.archivedNotOpenable') : undefined}
|
||||
onClick={() => { onOpen(node.id) }}
|
||||
onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }}
|
||||
onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }}
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { WorkspaceBrowserProps } from '../contract/slots.ts'
|
||||
import type { ArchivedFilter, GroupNode, SessionNode, SessionOrderBy, SessionRowState } from '../tree.ts'
|
||||
import {
|
||||
deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder,
|
||||
pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY, visibleSessionIds,
|
||||
pinCurrentBlank, reconcileManualOrder, sessionMemberIds, UNGROUPED_KEY,
|
||||
} from '../tree.ts'
|
||||
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
|
||||
import { AnimatedRows } from './AnimatedRows.tsx'
|
||||
@@ -101,7 +101,7 @@ function useNativeDragAcceptance(active: boolean): void {
|
||||
}
|
||||
|
||||
/** Grouping, ordering, and archived-filter menu; own open state so it resets with the wide chrome. */
|
||||
function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrderPick, onArchivedFilterPick, openSeq, anchorRef, t }: {
|
||||
function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrderPick, onArchivedFilterPick, openSeq, t }: {
|
||||
groupBy: SessionGroupBy
|
||||
orderBy: SessionOrderBy
|
||||
archivedFilter: ArchivedFilter
|
||||
@@ -110,8 +110,6 @@ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrde
|
||||
onArchivedFilterPick: (filter: ArchivedFilter) => void
|
||||
/** Each bump above zero opens the menu (the archive toast's filter action). */
|
||||
openSeq?: number
|
||||
/** Receives the trigger button element, so the archive hint can anchor under it. */
|
||||
anchorRef?: ((el: HTMLButtonElement | null) => void) | undefined
|
||||
t: WorkspaceBrowserProps['t']
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -166,7 +164,6 @@ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrde
|
||||
anchor={(
|
||||
<Tooltip label={t('viewOptions.label')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.wide)}
|
||||
aria-label={t('viewOptions.label')}
|
||||
@@ -194,6 +191,30 @@ interface DragState {
|
||||
over: { id: SessionNode['id']; half: 'before' | 'after' } | null
|
||||
}
|
||||
|
||||
/** Apply a visible drop to the complete account without removing hidden members. */
|
||||
function sessionDragOrder(
|
||||
order: readonly SessionId[],
|
||||
rows: readonly SessionNode[],
|
||||
drag: DragState,
|
||||
over: NonNullable<DragState['over']>,
|
||||
): SessionId[] | undefined {
|
||||
const source = rows.find(row => row.id === drag.sessionId)
|
||||
const target = rows.find(row => row.id === over.id)
|
||||
if (source === undefined || target === undefined || source.blank
|
||||
|| source.pinned !== drag.pinned || target.pinned !== drag.pinned
|
||||
|| source.id === target.id || !order.includes(source.id)) return
|
||||
const section = rows.filter(row => row.pinned === drag.pinned)
|
||||
const sourceIndex = section.findIndex(row => row.id === source.id)
|
||||
const withoutSource = section.filter(row => row.id !== source.id)
|
||||
const insertAt = withoutSource.findIndex(row => row.id === target.id) + (over.half === 'after' ? 1 : 0)
|
||||
if (insertAt === sourceIndex) return
|
||||
const next = order.filter(id => id !== source.id)
|
||||
const targetIndex = next.indexOf(target.id)
|
||||
if (targetIndex === -1) return
|
||||
next.splice(targetIndex + (over.half === 'after' ? 1 : 0), 0, source.id)
|
||||
return pinCurrentBlank(next, rows.find(row => row.blank)?.id)
|
||||
}
|
||||
|
||||
/** In-flight Workspace-row drag: source identity plus the current marker. */
|
||||
interface WorkspaceDragState {
|
||||
workspaceId: WorkspaceId
|
||||
@@ -239,7 +260,7 @@ type SessionTreeProps = Pick<
|
||||
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned session rename dialog. */
|
||||
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
|
||||
/** Archive a session (row menu action; the row disappears on the state echo). */
|
||||
/** Archive a Session; the selected filter determines its visibility. */
|
||||
onSessionArchive: (sessionId: SessionNode['id']) => void
|
||||
/** Unarchive a session (row menu action on archived rows). */
|
||||
onSessionUnarchive: (sessionId: SessionNode['id']) => void
|
||||
@@ -338,63 +359,11 @@ function SessionTree({
|
||||
const accountSessionIds = activeDrag.accountKey === UNGROUPED_KEY
|
||||
? ungroupedSessionIds
|
||||
: workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds
|
||||
if (accountSessionIds === undefined || !accountSessionIds.includes(activeDrag.sessionId)) return
|
||||
const currentBlank = group.sessions.find(node => node.blank)?.id
|
||||
if (activeDrag.pinned) {
|
||||
// Pinned rows render as one leading block in group order; reorder that
|
||||
// sequence and write it back into the pinned rows' account slots, so
|
||||
// every other member (hidden ones included) keeps its position.
|
||||
const pinnedSeq = group.sessions.filter(node => node.pinned).map(node => node.id)
|
||||
const nextSeq = pinnedSeq.filter(id => id !== activeDrag.sessionId)
|
||||
const at = nextSeq.indexOf(over.id)
|
||||
// Pin state can move under an in-flight drag (a Host pin event lands).
|
||||
if (at === -1 || !pinnedSeq.includes(activeDrag.sessionId)) return
|
||||
nextSeq.splice(over.half === 'before' ? at : at + 1, 0, activeDrag.sessionId)
|
||||
if (nextSeq.every((id, index) => id === pinnedSeq[index])) return
|
||||
const pinnedSlots = new Set(pinnedSeq)
|
||||
let cursor = 0
|
||||
// The cast is sound: nextSeq is a permutation of the pinned slots.
|
||||
const nextOrder = accountSessionIds.map(id =>
|
||||
pinnedSlots.has(id) ? nextSeq[cursor++] as SessionId : id)
|
||||
setSessionOrder(activeDrag.accountKey, pinCurrentBlank(nextOrder, currentBlank))
|
||||
return
|
||||
}
|
||||
if (accountSessionIds === undefined) return
|
||||
const sessionsExpanded = expandedSessionGroups.includes(group.key)
|
||||
const renderedSessions = sessionsExpanded ? group.sessions : collapsedSessionRows(group.sessions).rows
|
||||
const targetIndex = renderedSessions.findIndex(session => session.id === over.id)
|
||||
if (targetIndex === -1) return
|
||||
const sourceIndex = renderedSessions.findIndex(session => session.id === activeDrag.sessionId)
|
||||
const withoutSource = renderedSessions.filter(session => session.id !== activeDrag.sessionId)
|
||||
const targetWithoutSourceIndex = withoutSource.findIndex(session => session.id === over.id)
|
||||
if (targetWithoutSourceIndex === -1) return
|
||||
const visibleInsertAt = over.half === 'before' ? targetWithoutSourceIndex : targetWithoutSourceIndex + 1
|
||||
if (sourceIndex !== -1 && visibleInsertAt === sourceIndex) return
|
||||
const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId)
|
||||
let anchor: SessionId | undefined
|
||||
if (sessionsExpanded) {
|
||||
anchor = over.half === 'before' ? over.id : renderedSessions[targetIndex + 1]?.id
|
||||
} else {
|
||||
// Place the source at the visible boundary before hidden account members.
|
||||
const previousVisible = withoutSource[visibleInsertAt - 1]?.id
|
||||
if (previousVisible === undefined) {
|
||||
anchor = nextOrder[0]
|
||||
} else {
|
||||
const previousIndex = nextOrder.indexOf(previousVisible)
|
||||
if (previousIndex === -1) return
|
||||
anchor = nextOrder[previousIndex + 1]
|
||||
}
|
||||
}
|
||||
const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
|
||||
nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
|
||||
if (!sessionsExpanded && sourceIndex !== -1) {
|
||||
const nodes = new Map(group.sessions.map(node => [node.id, node]))
|
||||
const nextGroup = nextOrder.flatMap((id) => {
|
||||
const node = nodes.get(id)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
if (!collapsedSessionRows(nextGroup).rows.some(node => node.id === activeDrag.sessionId)) return
|
||||
}
|
||||
setSessionOrder(activeDrag.accountKey, pinCurrentBlank(nextOrder, currentBlank))
|
||||
const nextOrder = sessionDragOrder(accountSessionIds, renderedSessions, activeDrag, over)
|
||||
if (nextOrder !== undefined) setSessionOrder(activeDrag.accountKey, nextOrder)
|
||||
}
|
||||
const commitWorkspaceDrag = (
|
||||
activeDrag: WorkspaceDragState,
|
||||
@@ -682,18 +651,8 @@ function FlatList({
|
||||
if (dropCommitted.current) return
|
||||
dropCommitted.current = true
|
||||
setDrag(null)
|
||||
const targetIndex = rows.findIndex(row => row.id === over.id)
|
||||
if (targetIndex === -1) return
|
||||
const anchor = over.half === 'before' ? over.id : rows[targetIndex + 1]?.id
|
||||
if (anchor === activeDrag.sessionId) return
|
||||
const sourceIndex = rows.findIndex(row => row.id === activeDrag.sessionId)
|
||||
const anchorIndex = anchor === undefined ? rows.length : rows.findIndex(row => row.id === anchor)
|
||||
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
||||
const nextOrder = rows.map(row => row.id).filter(id => id !== activeDrag.sessionId)
|
||||
const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
|
||||
nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
|
||||
const currentBlank = rows.find(node => node.blank)?.id
|
||||
setSessionOrder(FLAT_SESSION_ORDER_KEY, pinCurrentBlank(nextOrder, currentBlank))
|
||||
const nextOrder = sessionDragOrder(sessionIds, rows, activeDrag, over)
|
||||
if (nextOrder !== undefined) setSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder)
|
||||
}
|
||||
const now = Date.now()
|
||||
return (
|
||||
@@ -709,8 +668,6 @@ function FlatList({
|
||||
<div className={css.empty} data-row-key="empty">{t('empty.none')}</div>
|
||||
)}
|
||||
{rows.map((node) => {
|
||||
// Pinned rows reorder only within their leading pinned block; the
|
||||
// wholesale rendered-order write then keeps that block contiguous.
|
||||
const active = drag !== null && drag.pinned === node.pinned
|
||||
const normalizeHalf = (half: 'before' | 'after'): 'before' | 'after' =>
|
||||
node.blank ? 'after' : half
|
||||
@@ -901,7 +858,7 @@ export function WorkspaceBrowser({
|
||||
const workspacePhase = useWorkspaces(state => state.phase)
|
||||
const workspaceStreamState = useWorkspaces(state => state.state)
|
||||
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
|
||||
const pinnedSessions = useWorkspaces(state => state.pinnedSessions)
|
||||
const pinnedSessionIds = useWorkspaces(state => state.pinnedSessionIds)
|
||||
// Live occupancy of this surface's directory-flow hole (the same source the
|
||||
// flow reads): a composition without a picking affordance can add nothing.
|
||||
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
|
||||
@@ -912,7 +869,6 @@ export function WorkspaceBrowser({
|
||||
const archivedFilter = useStore(s => s.archivedFilter ?? 'default')
|
||||
const groupExpansion = useStore(s => s.groupExpansion)
|
||||
const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount)
|
||||
const viewOptionsButton = useRef<HTMLButtonElement | null>(null)
|
||||
// Each bump opens the view-options menu (the archive toast's filter action).
|
||||
const [viewOptionsOpenSeq, setViewOptionsOpenSeq] = useState(0)
|
||||
// One transient banner at a time; the seq keys remounts so a repeat message restarts its hold.
|
||||
@@ -944,25 +900,20 @@ export function WorkspaceBrowser({
|
||||
const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
|
||||
return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id))
|
||||
}, [list, workspaces])
|
||||
const orderState = useMemo(
|
||||
() => ({ pinnedSessionIds, archivedSessionIds }),
|
||||
[archivedSessionIds, pinnedSessionIds],
|
||||
)
|
||||
const rowState = useMemo<SessionRowState>(
|
||||
() => ({ pinnedSessions, archivedSessionIds, archivedFilter }),
|
||||
[archivedSessionIds, pinnedSessions, archivedFilter],
|
||||
)
|
||||
// Pinned rows rank by the later of their pin instant and update recency, so
|
||||
// a newly pinned or newly updated pinned row leads the pinned block.
|
||||
const pinnedAtBySession = useMemo(
|
||||
() => new Map(pinnedSessions.map(entry => [entry.sessionId, entry.pinnedAt])),
|
||||
[pinnedSessions],
|
||||
)
|
||||
const flatMemberIds = useMemo(
|
||||
() => visibleSessionIds(list, archivedSessionIds, archivedFilter),
|
||||
[archivedSessionIds, list, archivedFilter],
|
||||
() => ({ ...orderState, archivedFilter }),
|
||||
[orderState, archivedFilter],
|
||||
)
|
||||
const flatMemberIds = useMemo(() => sessionMemberIds(list), [list])
|
||||
const orderedWorkspaces = useMemo(() => workspaces.map((workspace) => {
|
||||
const memberIds = workspace.sessionIds
|
||||
const baseOrder = orderBy === 'updated'
|
||||
? orderByRecency(memberIds, list.byId, pinnedAtBySession)
|
||||
: reconcileManualOrder(memberIds, sessionOrderByAccount[workspace.workspaceId], list.byId)
|
||||
? orderByRecency(memberIds, list.byId)
|
||||
: reconcileManualOrder(memberIds, sessionOrderByAccount[workspace.workspaceId], list.byId, orderState)
|
||||
return {
|
||||
...workspace,
|
||||
sessionIds: pinCurrentBlank(
|
||||
@@ -970,30 +921,33 @@ export function WorkspaceBrowser({
|
||||
currentBlank !== undefined && memberIds.includes(currentBlank) ? currentBlank : undefined,
|
||||
),
|
||||
}
|
||||
}), [currentBlank, list.byId, orderBy, pinnedAtBySession, sessionOrderByAccount, workspaces])
|
||||
}), [currentBlank, list.byId, orderBy, orderState, sessionOrderByAccount, workspaces])
|
||||
const orderedUngroupedSessionIds = useMemo(() => {
|
||||
const baseOrder = orderBy === 'updated'
|
||||
? orderByRecency(ungroupedMemberIds, list.byId, pinnedAtBySession)
|
||||
: reconcileManualOrder(ungroupedMemberIds, sessionOrderByAccount[UNGROUPED_KEY], list.byId)
|
||||
? orderByRecency(ungroupedMemberIds, list.byId)
|
||||
: reconcileManualOrder(ungroupedMemberIds, sessionOrderByAccount[UNGROUPED_KEY], list.byId, orderState)
|
||||
return pinCurrentBlank(
|
||||
baseOrder,
|
||||
currentBlank !== undefined && ungroupedMemberIds.includes(currentBlank) ? currentBlank : undefined,
|
||||
)
|
||||
}, [currentBlank, list.byId, orderBy, pinnedAtBySession, sessionOrderByAccount, ungroupedMemberIds])
|
||||
}, [currentBlank, list.byId, orderBy, orderState, sessionOrderByAccount, ungroupedMemberIds])
|
||||
const orderedFlatSessionIds = useMemo(() => {
|
||||
const baseOrder = orderBy === 'updated'
|
||||
? orderByRecency(flatMemberIds, list.byId, pinnedAtBySession)
|
||||
: reconcileManualOrder(flatMemberIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY], list.byId)
|
||||
? orderByRecency(flatMemberIds, list.byId)
|
||||
: reconcileManualOrder(flatMemberIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY], list.byId, orderState)
|
||||
return pinCurrentBlank(
|
||||
baseOrder,
|
||||
currentBlank !== undefined && flatMemberIds.includes(currentBlank) ? currentBlank : undefined,
|
||||
)
|
||||
}, [currentBlank, flatMemberIds, list.byId, orderBy, pinnedAtBySession, sessionOrderByAccount])
|
||||
const activeSessionOrders = useMemo<Readonly<Record<string, readonly string[]>>>(() => Object.fromEntries([
|
||||
}, [currentBlank, flatMemberIds, list.byId, orderBy, orderState, sessionOrderByAccount])
|
||||
const activeSessionOrders = useMemo<Readonly<Record<string, readonly SessionId[]>>>(() => Object.fromEntries([
|
||||
...orderedWorkspaces.map(workspace => [workspace.workspaceId, workspace.sessionIds] as const),
|
||||
[UNGROUPED_KEY, orderedUngroupedSessionIds] as const,
|
||||
[FLAT_SESSION_ORDER_KEY, orderedFlatSessionIds] as const,
|
||||
]), [orderedFlatSessionIds, orderedUngroupedSessionIds, orderedWorkspaces])
|
||||
const pinOrderSource = { workspaces, ungroupedMemberIds, flatMemberIds, summaries: list.byId, rowState: orderState }
|
||||
const latestPinOrder = useRef(pinOrderSource)
|
||||
latestPinOrder.current = pinOrderSource
|
||||
useEffect(() => {
|
||||
if (workspacePhase !== 'ready') return
|
||||
actions.retainAccountKeys([
|
||||
@@ -1025,15 +979,14 @@ export function WorkspaceBrowser({
|
||||
workspaceReady,
|
||||
])
|
||||
useEffect(() => {
|
||||
if (list.phase !== 'ready' || !workspaceReady || orderBy !== 'manual') return
|
||||
const changed = Object.fromEntries(Object.entries(activeSessionOrders).filter(([key, ids]) => {
|
||||
const saved = sessionOrderByAccount[key]
|
||||
return saved === undefined || saved.length !== ids.length || ids.some((id, index) => id !== saved[index])
|
||||
}))
|
||||
if (Object.keys(changed).length > 0) actions.syncSessionOrders(changed)
|
||||
if (list.phase !== 'ready' || !workspaceReady || orderBy !== 'manual' || currentBlank === undefined) return
|
||||
const moved = Object.entries(activeSessionOrders).some(([key, ids]) =>
|
||||
ids[0] === currentBlank && sessionOrderByAccount[key]?.[0] !== currentBlank)
|
||||
if (moved) actions.syncSessionOrders(activeSessionOrders)
|
||||
}, [
|
||||
actions.syncSessionOrders,
|
||||
activeSessionOrders,
|
||||
currentBlank,
|
||||
list.phase,
|
||||
orderBy,
|
||||
sessionOrderByAccount,
|
||||
@@ -1212,10 +1165,8 @@ export function WorkspaceBrowser({
|
||||
setSessionRenameError(null)
|
||||
}
|
||||
|
||||
// Archive is dialog-free: not destructive (the log and the accounting slot
|
||||
// remain), so the menu action commits directly; the row disappears when the
|
||||
// archive-set echo lands. Failures are non-fatal console diagnostics, the
|
||||
// same posture as reorder rejections.
|
||||
// Archive preserves the log and account position, so it needs no confirmation.
|
||||
// The selected filter determines visibility after the archive-set echo.
|
||||
const onSessionArchive = (sessionId: SessionNode['id']) => {
|
||||
archiveSession(sessionId).then(() => {
|
||||
setToast(current => ({
|
||||
@@ -1244,16 +1195,21 @@ export function WorkspaceBrowser({
|
||||
// surface moves, so a silent failure would read as a dead menu action.
|
||||
const onSessionPin = (sessionId: SessionNode['id'], pin: boolean) => {
|
||||
(pin ? pinSession(sessionId) : unpinSession(sessionId)).then(() => {
|
||||
// In manual mode a fresh pin also fronts the row's manual slot, so the
|
||||
// pinned block starts in pin order; drags within the block still adjust
|
||||
// it. The slot is not restored on unpin: the row stays at the top.
|
||||
if (!pin || orderBy !== 'manual') return
|
||||
actions.syncSessionOrders(Object.fromEntries(
|
||||
[owningGroupKey(workspaces, sessionId), FLAT_SESSION_ORDER_KEY].map((key) => {
|
||||
const saved = sessionOrderByAccount[key] ?? []
|
||||
return [key, [sessionId, ...saved.filter(id => id !== sessionId)]]
|
||||
}),
|
||||
))
|
||||
if (!pin) return
|
||||
const source = latestPinOrder.current
|
||||
actions.pinSessionOrder(
|
||||
sessionId,
|
||||
[owningGroupKey(source.workspaces, sessionId), FLAT_SESSION_ORDER_KEY],
|
||||
{
|
||||
members: Object.fromEntries([
|
||||
...source.workspaces.map(workspace => [workspace.workspaceId, workspace.sessionIds] as const),
|
||||
[UNGROUPED_KEY, source.ungroupedMemberIds],
|
||||
[FLAT_SESSION_ORDER_KEY, source.flatMemberIds],
|
||||
]),
|
||||
summaries: source.summaries,
|
||||
rowState: source.rowState,
|
||||
},
|
||||
)
|
||||
}).catch(() => {
|
||||
showToast(t(pin ? 'toast.pinFailed' : 'toast.unpinFailed'))
|
||||
})
|
||||
@@ -1369,7 +1325,6 @@ export function WorkspaceBrowser({
|
||||
onOrderPick={(mode) => { actions.setOrderBy(mode, activeSessionOrders) }}
|
||||
onArchivedFilterPick={actions.setArchivedFilter}
|
||||
openSeq={viewOptionsOpenSeq}
|
||||
anchorRef={(el) => { viewOptionsButton.current = el }}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* share from the return type.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ArchivedFilter } from './tree.ts'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { reconcileManualOrder, type ArchivedFilter, type SessionRowState } from './tree.ts'
|
||||
|
||||
/** Browser-local order account for the hierarchy-free flat Session list. */
|
||||
export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__'
|
||||
@@ -24,12 +26,14 @@ type WorkspaceViewState = {
|
||||
groupExpansion: Record<string, boolean>
|
||||
/** Saved manual order per Workspace group plus the browser-local flat-list account. */
|
||||
sessionOrderByAccount: Record<string, string[]>
|
||||
/**
|
||||
* Archived-row visibility, kept across reloads like the other view options.
|
||||
* Pre-filter v5 blobs lack the field; the browser reads it with a 'default'
|
||||
* fallback at the rehydration boundary.
|
||||
*/
|
||||
archivedFilter: ArchivedFilter
|
||||
/** Archived-row visibility; omitted in pre-filter v5 snapshots and read as 'default'. */
|
||||
archivedFilter?: ArchivedFilter
|
||||
}
|
||||
|
||||
type SessionOrderSource = {
|
||||
members: Readonly<Record<string, readonly SessionId[]>>
|
||||
summaries: SessionListState['byId']
|
||||
rowState: Pick<SessionRowState, 'pinnedSessionIds' | 'archivedSessionIds'>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +59,12 @@ type WorkspaceViewActions = {
|
||||
order: readonly string[],
|
||||
initialOrders: Readonly<Record<string, readonly string[]>>,
|
||||
) => void
|
||||
pinSessionOrder: (
|
||||
draft: WorkspaceViewState,
|
||||
sessionId: string,
|
||||
accountKeys: readonly string[],
|
||||
source: SessionOrderSource,
|
||||
) => void
|
||||
setArchivedFilter: (draft: WorkspaceViewState, filter: ArchivedFilter) => void
|
||||
}
|
||||
|
||||
@@ -103,9 +113,17 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
|
||||
},
|
||||
setSessionOrder: (d, accountKey, order, initialOrders) => {
|
||||
if (d.orderBy === 'updated') d.sessionOrderByAccount = copySessionOrders(initialOrders)
|
||||
else Object.assign(d.sessionOrderByAccount, copySessionOrders(initialOrders))
|
||||
d.orderBy = 'manual'
|
||||
d.sessionOrderByAccount[accountKey] = [...order]
|
||||
},
|
||||
pinSessionOrder: (d, sessionId, accountKeys, source) => {
|
||||
const selected = new Set(accountKeys)
|
||||
d.sessionOrderByAccount = Object.fromEntries(Object.entries(source.members).map(([key, members]) => {
|
||||
const order = reconcileManualOrder(members, d.sessionOrderByAccount[key], source.summaries, source.rowState)
|
||||
return [key, selected.has(key) ? [sessionId, ...order.filter(id => id !== sessionId)] : order]
|
||||
}))
|
||||
},
|
||||
setArchivedFilter: (d, filter: ArchivedFilter) => { d.archivedFilter = filter },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import {
|
||||
type SessionListState, type SessionSearchResultItem, type SessionSummary,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { WorkspaceId, WorkspacePinnedSession, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type {
|
||||
SessionStatusSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
@@ -146,20 +146,16 @@ export function workspaceLabel(cwd: string | undefined): string {
|
||||
* Project known account members by current Session recency.
|
||||
* @param sessionIds - authoritative account membership.
|
||||
* @param summaries - current Session summaries; members without a summary are omitted until it arrives.
|
||||
* @param pinnedAt - pin instants by Session; a pinned row ranks by the later of its pin instant and update recency.
|
||||
* @returns known members newest first, with Session identity as the deterministic tie-break.
|
||||
*/
|
||||
export function orderByRecency(
|
||||
sessionIds: readonly SessionId[],
|
||||
summaries: SessionListState['byId'],
|
||||
pinnedAt?: ReadonlyMap<SessionId, number>,
|
||||
): SessionId[] {
|
||||
return sessionIds.flatMap((id) => {
|
||||
const summary = summaries[id]
|
||||
if (summary === undefined) return []
|
||||
const pin = pinnedAt?.get(id)
|
||||
const rank = pin === undefined ? summary.updatedAt : Math.max(pin, summary.updatedAt)
|
||||
return [{ id, rank }]
|
||||
return [{ id, rank: summary.updatedAt }]
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.rank !== b.rank) return b.rank - a.rank
|
||||
@@ -173,12 +169,14 @@ export function orderByRecency(
|
||||
* @param memberIds - authoritative account membership.
|
||||
* @param savedOrder - previously saved browser-local order.
|
||||
* @param summaries - current Session summaries used to append newly known members by recency.
|
||||
* @returns retained saved slots followed by newly known members; departed members and unknown new members are omitted.
|
||||
* @param rowState - global pin and archive membership; only account members can supplement the order.
|
||||
* @returns missing pins, retained saved slots, new ordinary members, then missing archives; unknown new members wait for their summaries.
|
||||
*/
|
||||
export function reconcileManualOrder(
|
||||
memberIds: readonly SessionId[],
|
||||
savedOrder: readonly string[] | undefined,
|
||||
summaries: SessionListState['byId'],
|
||||
rowState?: Pick<SessionRowState, 'pinnedSessionIds' | 'archivedSessionIds'>,
|
||||
): SessionId[] {
|
||||
const members = new Map(memberIds.map(id => [id as string, id]))
|
||||
const included = new Set<string>()
|
||||
@@ -189,12 +187,21 @@ export function reconcileManualOrder(
|
||||
ordered.push(id)
|
||||
included.add(key)
|
||||
}
|
||||
for (const id of orderByRecency(memberIds, summaries)) {
|
||||
if (included.has(id)) continue
|
||||
ordered.push(id)
|
||||
const archived = new Set(rowState?.archivedSessionIds)
|
||||
const pins: SessionId[] = []
|
||||
for (const sessionId of rowState?.pinnedSessionIds ?? []) {
|
||||
const id = members.get(sessionId)
|
||||
if (id === undefined || included.has(id) || archived.has(id) || summaries[id] === undefined) continue
|
||||
pins.push(id)
|
||||
included.add(id)
|
||||
}
|
||||
return ordered
|
||||
const ordinary: SessionId[] = []
|
||||
const archives: SessionId[] = []
|
||||
for (const id of orderByRecency([...members.values()].filter(id => !included.has(id)), summaries)) {
|
||||
if (archived.has(id)) archives.push(id)
|
||||
else ordinary.push(id)
|
||||
}
|
||||
return [...pins, ...ordered, ...ordinary, ...archives]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,8 +254,8 @@ function sessionVisible(
|
||||
|
||||
/** Registry-global row state consumed by every tree derivation. */
|
||||
export interface SessionRowState {
|
||||
/** Pin set with pin instants; pinned rows lead their section in the local order. */
|
||||
pinnedSessions: readonly WorkspacePinnedSession[]
|
||||
/** Registry-global pin ids; pinned rows lead their section in the local order. */
|
||||
pinnedSessionIds: readonly SessionId[]
|
||||
/** Archive set; members keep their slots and show grayed while visible. */
|
||||
archivedSessionIds: readonly SessionId[]
|
||||
/** Archived-row visibility choice applied to lists and search alike. */
|
||||
@@ -256,24 +263,23 @@ export interface SessionRowState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Move pinned rows to the front without disturbing relative order on either
|
||||
* side of the partition. Pinned rows keep the caller order among themselves —
|
||||
* recency or the persisted manual order, so pinned-to-pinned drags hold — and
|
||||
* archived rows keep their caller slots: the order never absorbs pin state,
|
||||
* so unpinning restores the row's kept position.
|
||||
* Keep the visible New Session placeholder first, then partition pinned and
|
||||
* ordinary rows without changing either partition's caller order.
|
||||
*/
|
||||
function sectionMembers(
|
||||
members: readonly SessionSummary[],
|
||||
pinned: ReadonlySet<SessionId>,
|
||||
archived: ReadonlySet<SessionId>,
|
||||
): SessionSummary[] {
|
||||
const placeholders: SessionSummary[] = []
|
||||
const leading: SessionSummary[] = []
|
||||
const rest: SessionSummary[] = []
|
||||
for (const member of members) {
|
||||
if (!archived.has(member.id) && pinned.has(member.id)) leading.push(member)
|
||||
if (member.blank) placeholders.push(member)
|
||||
else if (!archived.has(member.id) && pinned.has(member.id)) leading.push(member)
|
||||
else rest.push(member)
|
||||
}
|
||||
return [...leading, ...rest]
|
||||
return [...placeholders, ...leading, ...rest]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,7 +431,7 @@ export function deriveGroups(
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const archived = new Set(rowState.archivedSessionIds)
|
||||
const pinned = new Set(rowState.pinnedSessions.map(entry => entry.sessionId))
|
||||
const pinned = new Set(rowState.pinnedSessionIds)
|
||||
const expandedGroups = new Set(view.expandedGroups)
|
||||
const descendants = indexSubagentDescendants(list.byId)
|
||||
const current = mainSessionId(list)
|
||||
@@ -454,7 +460,16 @@ export function deriveGroups(
|
||||
}
|
||||
|
||||
/**
|
||||
* Select flat-list members without deriving row presentation or ordering.
|
||||
* Select complete flat-list membership, independently of archive visibility.
|
||||
* @param list - sessions list snapshot.
|
||||
* @returns known ordinary Session ids, including archives and only the current blank.
|
||||
*/
|
||||
export function sessionMemberIds(list: SessionListState): SessionId[] {
|
||||
return visibleSessionIds(list, [], 'show')
|
||||
}
|
||||
|
||||
/**
|
||||
* Select visible flat-list members without deriving row presentation or ordering.
|
||||
* @param list - sessions list snapshot.
|
||||
* @param archivedSessionIds - registry-global archive set.
|
||||
* @param archivedFilter - archived-row visibility choice.
|
||||
@@ -474,10 +489,10 @@ export function visibleSessionIds(
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive flat rows from the browser's ordered visible Session ids, with
|
||||
* Derive flat rows from the browser's complete ordered Session ids, with
|
||||
* pinned rows fronted ahead of the supplied order.
|
||||
* @param list - sessions list snapshot used to select the ids.
|
||||
* @param sessionIds - known visible members in render order, including any pinned blank.
|
||||
* @param sessionIds - complete account members in the selected order, including hidden archives.
|
||||
* @param rowState - registry-global pin and archive sets plus the archived filter.
|
||||
* @param statuses - unified UI status by Session.
|
||||
* @returns flat rows in sectioned order with current status indicators.
|
||||
@@ -489,9 +504,16 @@ export function deriveFlat(
|
||||
statuses: SessionStatuses,
|
||||
): SessionNode[] {
|
||||
const archived = new Set(rowState.archivedSessionIds)
|
||||
const pinned = new Set(rowState.pinnedSessions.map(entry => entry.sessionId))
|
||||
const pinned = new Set(rowState.pinnedSessionIds)
|
||||
const descendants = indexSubagentDescendants(list.byId)
|
||||
return sectionMembers(sessionIds.map(id => list.byId[id] as SessionSummary), pinned, archived)
|
||||
const current = mainSessionId(list)
|
||||
const members = sessionIds.flatMap((id) => {
|
||||
const session = list.byId[id]
|
||||
return session !== undefined && sessionVisible(session, current, archived, rowState.archivedFilter)
|
||||
? [session]
|
||||
: []
|
||||
})
|
||||
return sectionMembers(members, pinned, archived)
|
||||
.map(session => sessionNode(session, descendants, statuses, pinned, archived))
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ async function bench() {
|
||||
ctx.provide('workspaces', {
|
||||
list: {
|
||||
getSnapshot: () => ({
|
||||
items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
}),
|
||||
subscribe,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
type ArchivedFilter,
|
||||
deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder,
|
||||
pinCurrentBlank, reconcileManualOrder, visibleSessionIds, workspaceLabel, UNGROUPED_KEY,
|
||||
pinCurrentBlank, reconcileManualOrder, sessionMemberIds, visibleSessionIds, workspaceLabel, UNGROUPED_KEY,
|
||||
} from '../src/client/tree.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
|
||||
@@ -51,7 +51,7 @@ const rowState = (options: {
|
||||
archived?: readonly string[]
|
||||
archivedFilter?: ArchivedFilter
|
||||
} = {}) => ({
|
||||
pinnedSessions: (options.pinned ?? []).map(id => ({ sessionId: sid(id), pinnedAt: 1 })),
|
||||
pinnedSessionIds: (options.pinned ?? []).map(sid),
|
||||
archivedSessionIds: (options.archived ?? []).map(sid),
|
||||
archivedFilter: options.archivedFilter ?? 'default' as const,
|
||||
})
|
||||
@@ -72,6 +72,26 @@ describe('owningGroupKey', () => {
|
||||
})
|
||||
|
||||
describe('Session ordering', () => {
|
||||
it.each(['workspace', 'ungrouped', 'flat'] as const)('keeps the current New Session before pins in %s', (mode) => {
|
||||
const sessions = withMain(list(
|
||||
summary('pin', 30),
|
||||
summary('ordinary', 20),
|
||||
{ ...summary('blank', 0), blank: true },
|
||||
), sid('blank'))
|
||||
const order = pinCurrentBlank(sessions.ids, sid('blank'))
|
||||
const state = rowState({ pinned: ['pin'] })
|
||||
const rows = mode === 'flat'
|
||||
? deriveFlat(sessions, order, state, noAttention)
|
||||
: deriveGroups(
|
||||
sessions,
|
||||
mode === 'workspace' ? [workspace('alpha', order)] : [],
|
||||
state,
|
||||
noAttention,
|
||||
view([mode === 'workspace' ? 'alpha' : UNGROUPED_KEY], order),
|
||||
)[0]!.sessions
|
||||
expect(rows.map(row => row.id)).toEqual([sid('blank'), sid('pin'), sid('ordinary')])
|
||||
})
|
||||
|
||||
it('orders known members by recency with a stable identity tie-break', () => {
|
||||
const summaries = list(summary('tie-b', 20), summary('older', 10), summary('tie-a', 20)).byId
|
||||
expect(orderByRecency(
|
||||
@@ -80,14 +100,18 @@ describe('Session ordering', () => {
|
||||
)).toEqual([sid('tie-a'), sid('tie-b'), sid('older')])
|
||||
})
|
||||
|
||||
it('ranks a pinned member by the later of its pin instant and update recency', () => {
|
||||
const summaries = list(summary('stale-pin', 10), summary('fresh-pin', 30), summary('plain', 20)).byId
|
||||
const pinnedAt = new Map([[sid('stale-pin'), 25], [sid('fresh-pin'), 5]])
|
||||
expect(orderByRecency(
|
||||
[sid('stale-pin'), sid('fresh-pin'), sid('plain')],
|
||||
summaries,
|
||||
pinnedAt,
|
||||
)).toEqual([sid('fresh-pin'), sid('stale-pin'), sid('plain')])
|
||||
it('orders each partition strictly by Session recency, independent of pin-array order', () => {
|
||||
const sessions = list(summary('stale-pin', 10), summary('fresh-pin', 30), summary('plain', 20))
|
||||
const state = {
|
||||
...noRows,
|
||||
pinnedSessionIds: [
|
||||
sid('stale-pin'),
|
||||
sid('fresh-pin'),
|
||||
],
|
||||
}
|
||||
const order = orderByRecency(sessions.ids, sessions.byId)
|
||||
expect(deriveFlat(sessions, order, state, noAttention).map(row => row.id))
|
||||
.toEqual([sid('fresh-pin'), sid('stale-pin'), sid('plain')])
|
||||
})
|
||||
|
||||
it('reconciles retained manual slots and appends newly known members by recency', () => {
|
||||
@@ -101,6 +125,39 @@ describe('Session ordering', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('supplements only missing account members with pins first and archives last', () => {
|
||||
const sessions = list(
|
||||
summary('saved-archive', 80), summary('saved-plain', 1), summary('saved-pin', 2),
|
||||
summary('pin-a', 5), summary('pin-b', 6), summary('new-plain', 100),
|
||||
summary('archive-newer', 90), summary('archive-older', 10), summary('both', 50),
|
||||
summary('outside', 200),
|
||||
)
|
||||
const members = [...sessions.ids.filter(id => id !== 'outside'), sid('pending-pin')]
|
||||
const saved = ['saved-archive', 'saved-plain', 'saved-pin']
|
||||
const state = rowState({
|
||||
pinned: ['outside', 'pin-b', 'saved-pin', 'both', 'pin-a', 'pending-pin'],
|
||||
archived: ['archive-older', 'saved-archive', 'both', 'archive-newer'],
|
||||
})
|
||||
const complete = reconcileManualOrder(members, saved, sessions.byId, state)
|
||||
expect(complete).toEqual([
|
||||
'pin-b', 'pin-a', 'saved-archive', 'saved-plain', 'saved-pin', 'new-plain',
|
||||
'archive-newer', 'both', 'archive-older',
|
||||
])
|
||||
expect(reconcileManualOrder(members, complete, sessions.byId, state)).toEqual(complete)
|
||||
expect(saved).toEqual(['saved-archive', 'saved-plain', 'saved-pin'])
|
||||
})
|
||||
|
||||
it('keeps archive filtering out of complete flat membership', () => {
|
||||
const sessions = list(summary('plain', 1), summary('archived', 2))
|
||||
const members = sessionMemberIds(sessions)
|
||||
expect(members).toEqual(['plain', 'archived'])
|
||||
expect(deriveFlat(sessions, members, rowState({ archived: ['archived'] }), noAttention).map(row => row.id))
|
||||
.toEqual(['plain'])
|
||||
expect(deriveFlat(sessions, members, rowState({ archived: ['archived'], archivedFilter: 'only' }), noAttention)
|
||||
.map(row => row.id)).toEqual(['archived'])
|
||||
expect(members).toEqual(['plain', 'archived'])
|
||||
})
|
||||
|
||||
it('pins only the selected blank without changing the base order', () => {
|
||||
expect(pinCurrentBlank([sid('newer'), sid('blank'), sid('older')], sid('blank')))
|
||||
.toEqual([sid('blank'), sid('newer'), sid('older')])
|
||||
@@ -178,7 +235,7 @@ describe('deriveGroups', () => {
|
||||
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])],
|
||||
noRows, noAttention, view(['first']),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([currentBlank.id, real.id])
|
||||
const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
|
||||
// The stored placeholder title stays canonical; the renderer swaps in
|
||||
// the localized New Session label via the blank flag.
|
||||
@@ -667,6 +724,40 @@ describe('createWorkspaceViewStore', () => {
|
||||
expect(store.getSnapshot().sessionOrderByAccount).toEqual({})
|
||||
})
|
||||
|
||||
it('saves Pin positions and complete accounts without changing the selected ordering mode', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
store.actions.pinSessionOrder('old', ['alpha'], {
|
||||
members: {
|
||||
alpha: [sid('new'), sid('archive'), sid('old')],
|
||||
beta: [sid('other-archive')],
|
||||
},
|
||||
summaries: list(summary('new', 3), summary('old', 2), summary('archive', 1), summary('other-archive', 0)).byId,
|
||||
rowState: rowState({ archived: ['archive', 'other-archive'] }),
|
||||
})
|
||||
expect(store.getSnapshot()).toMatchObject({
|
||||
orderBy: 'updated',
|
||||
sessionOrderByAccount: { alpha: ['old', 'new', 'archive'], beta: ['other-archive'] },
|
||||
})
|
||||
store.actions.setOrderBy('manual', { alpha: ['new', 'old', 'archive'], beta: ['other-archive'] })
|
||||
store.actions.setSessionOrder('alpha', ['old', 'new', 'archive'], {
|
||||
alpha: ['new', 'old', 'archive'], beta: ['other-archive', 'missing-archive'],
|
||||
})
|
||||
expect(store.getSnapshot().sessionOrderByAccount)
|
||||
.toEqual({ alpha: ['old', 'new', 'archive'], beta: ['other-archive', 'missing-archive'] })
|
||||
})
|
||||
|
||||
it('uses current saved positions when a Pin source carries an earlier order', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
const source = {
|
||||
members: { alpha: [sid('a'), sid('b'), sid('c')] },
|
||||
summaries: list(summary('a', 3), summary('b', 2), summary('c', 1)).byId,
|
||||
rowState: noRows,
|
||||
}
|
||||
store.actions.setSessionOrder('alpha', ['b', 'a', 'c'], {})
|
||||
store.actions.pinSessionOrder('c', ['alpha'], source)
|
||||
expect(store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['c', 'b', 'a'])
|
||||
})
|
||||
|
||||
it('removes view state outside the retained Workspace key set', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
store.actions.setGroupExpanded('', true)
|
||||
|
||||
@@ -71,7 +71,7 @@ const workspaceState = (
|
||||
): WorkspaceSnapshot => ({
|
||||
items,
|
||||
archivedSessionIds,
|
||||
pinnedSessions: pinnedSessionIds.map(sessionId => ({ sessionId, pinnedAt: 1 })),
|
||||
pinnedSessionIds,
|
||||
state: 'idle',
|
||||
phase: 'ready',
|
||||
error: null,
|
||||
@@ -297,10 +297,7 @@ describe('WorkspaceBrowser', () => {
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([])) })
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().groupExpansion).toEqual({})
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({
|
||||
[UNGROUPED_KEY]: [],
|
||||
[FLAT_SESSION_ORDER_KEY]: [],
|
||||
})
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -346,7 +343,7 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { main: blank.id })),
|
||||
useWorkspaces: hook(groups(['old', 'blank'])),
|
||||
})
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old'])
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
|
||||
})
|
||||
|
||||
it('reconciles a late blank and its first prompt while the sidebar is collapsed', async () => {
|
||||
@@ -357,9 +354,7 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessionState([old])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank'])])),
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old'])
|
||||
})
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toBeUndefined()
|
||||
|
||||
rerender(b, {
|
||||
useSessions: hook(sessionState([old, blank], { main: blank.id })),
|
||||
@@ -398,6 +393,31 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('hides archived Sessions when a persisted v5 view has no archived filter', () => {
|
||||
const key = 'dsh.workspace.view.v5'
|
||||
const previous = localStorage.getItem(key)
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
groupBy: 'workspace', orderBy: 'manual', groupExpansion: { alpha: true }, sessionOrderByAccount: {},
|
||||
}))
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('alive', 2), summary('gone', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alive', 'gone'])], [sid('gone')])),
|
||||
})
|
||||
expect(b.store.getSnapshot().archivedFilter).toBeUndefined()
|
||||
expect(screen.getByText('alive')).toBeTruthy()
|
||||
expect(screen.queryByText('gone')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' }))
|
||||
expect(b.store.getSnapshot().archivedFilter).toBe('show')
|
||||
expect(screen.getByText('gone')).toBeTruthy()
|
||||
} finally {
|
||||
cleanup()
|
||||
if (previous === null) localStorage.removeItem(key)
|
||||
else localStorage.setItem(key, previous)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the grouped tree by default and switches to the flat list via Group by', () => {
|
||||
const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)])
|
||||
const b = mount({
|
||||
@@ -557,10 +577,7 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
|
||||
.toEqual(['one', 'two', 'three'])
|
||||
})
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toBeUndefined()
|
||||
|
||||
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
|
||||
const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement
|
||||
@@ -807,15 +824,13 @@ describe('WorkspaceBrowser', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“three”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' }))
|
||||
expect(pinSession).toHaveBeenCalledWith(sid('three'))
|
||||
// The seeded group order fronts the pin; the flat account starts from the
|
||||
// pinned row and the membership reconciler appends the remaining rows.
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'one', 'two'])
|
||||
})
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['three', 'one', 'two'])
|
||||
})
|
||||
|
||||
it('leaves saved orders alone for updated-mode pins and for unpins', async () => {
|
||||
it('saves a Pin reorder without leaving Last updated and leaves positions unchanged on unpin', async () => {
|
||||
localStorage.clear()
|
||||
const pinSession = vi.fn(async () => {})
|
||||
const unpinSession = vi.fn(async () => {})
|
||||
@@ -830,12 +845,16 @@ describe('WorkspaceBrowser', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' }))
|
||||
expect(pinSession).toHaveBeenCalledWith(sid('two'))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({
|
||||
alpha: ['two', 'one'],
|
||||
[UNGROUPED_KEY]: [],
|
||||
[FLAT_SESSION_ORDER_KEY]: ['two', 'one'],
|
||||
})
|
||||
expect(b.store.getSnapshot().orderBy).toBe('updated')
|
||||
|
||||
// Unpinning in manual mode does not rewrite any saved slot: the orders the
|
||||
// membership reconciler seeded on entering manual mode stay as they are.
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])], [], [sid('two')])) })
|
||||
act(() => { b.store.actions.setOrderBy('manual', {}) })
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
|
||||
const before = b.store.getSnapshot().sessionOrderByAccount
|
||||
expect(before).toEqual({
|
||||
alpha: ['one', 'two'],
|
||||
@@ -849,6 +868,138 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual(before)
|
||||
})
|
||||
|
||||
it('keeps newer saved orders when a pending Pin completes', async () => {
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const pinSession = vi.fn(() => pending.promise)
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
|
||||
pinSession,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“three”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' }))
|
||||
expect(pinSession).toHaveBeenCalledWith(sid('three'))
|
||||
act(() => {
|
||||
b.store.actions.setSessionOrder('alpha', ['two', 'one', 'three'], {})
|
||||
b.store.actions.setSessionOrder(FLAT_SESSION_ORDER_KEY, ['two', 'one', 'three'], {})
|
||||
})
|
||||
await act(async () => { pending.resolve(undefined) })
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'two', 'one'])
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['three', 'two', 'one'])
|
||||
})
|
||||
|
||||
it('uses current membership when a Workspace disappears during a pending Pin', async () => {
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
||||
pinSession: vi.fn(() => pending.promise),
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“two”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' }))
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([])) })
|
||||
await act(async () => { pending.resolve(undefined) })
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount).not.toHaveProperty('alpha')
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'one'])
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['two', 'one'])
|
||||
})
|
||||
|
||||
it('keeps saved Workspace members with temporarily missing summaries when pinning in Last updated', async () => {
|
||||
localStorage.clear()
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“one”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['one', 'two', 'three'])
|
||||
rerender(b, {
|
||||
useSessions: hook(sessionState([summary('one', 3), summary('three', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])], [], [sid('one')])),
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“three”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(b.store.getSnapshot().orderBy).toBe('updated')
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'one', 'two'])
|
||||
})
|
||||
|
||||
it.each(['workspace', 'flat', 'ungrouped'] as const)(
|
||||
'keeps complete %s order through archived filters and both drag partitions',
|
||||
async (mode) => {
|
||||
const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
|
||||
const saved = ['a', 'p', 'kept-archive', 'b', 'q', 'c']
|
||||
const preferences = createWorkspaceViewStore().create()
|
||||
preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
|
||||
preferences.actions.setGroupExpanded(account, true)
|
||||
preferences.actions.setSessionOrder(account, saved, {})
|
||||
const items = ['a', 'p', 'kept-archive', 'b', 'q', 'c', 'missing-archive']
|
||||
.map((id, index) => summary(id, 100 - index))
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState(items)),
|
||||
useWorkspaces: hook(workspaceState(
|
||||
mode === 'ungrouped' ? [] : [workspace('alpha', items.map(item => item.id))],
|
||||
[sid('kept-archive'), sid('missing-archive')],
|
||||
[sid('p'), sid('q')],
|
||||
)),
|
||||
})
|
||||
const names = () => screen.getAllByRole('treeitem')
|
||||
.filter(row => row.getAttribute('aria-expanded') === null)
|
||||
.map(row => row.querySelector('[class*="title"]')?.textContent)
|
||||
expect(names()).toEqual(['p', 'q', 'a', 'b', 'c'])
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(saved)
|
||||
|
||||
act(() => { b.store.actions.setArchivedFilter('only') })
|
||||
await waitFor(() => { expect(names()).toEqual(['kept-archive', 'missing-archive']) })
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(saved)
|
||||
act(() => { b.store.actions.setArchivedFilter('default') })
|
||||
await waitFor(() => { expect(names()).toEqual(['p', 'q', 'a', 'b', 'c']) })
|
||||
|
||||
const dragBefore = (sourceId: string, targetId: string): void => {
|
||||
const source = screen.getByText(sourceId).closest('[role="treeitem"]') as HTMLElement
|
||||
const target = screen.getByText(targetId).closest('[role="treeitem"]') as HTMLElement
|
||||
target.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(source, { dataTransfer: dragData() })
|
||||
fireDrag(target, 'drop', 105)
|
||||
}
|
||||
dragBefore('b', 'a')
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[account])
|
||||
.toEqual(['b', 'a', 'p', 'kept-archive', 'q', 'c', 'missing-archive'])
|
||||
await waitFor(() => { expect(names()).toEqual(['p', 'q', 'b', 'a', 'c']) })
|
||||
dragBefore('q', 'p')
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[account])
|
||||
.toEqual(['b', 'a', 'q', 'p', 'kept-archive', 'c', 'missing-archive'])
|
||||
await waitFor(() => { expect(names()).toEqual(['q', 'p', 'b', 'a', 'c']) })
|
||||
b.view.unmount()
|
||||
|
||||
const restored = mount({ useSessions: b.props.useSessions, useWorkspaces: b.props.useWorkspaces })
|
||||
expect(names()).toEqual(['q', 'p', 'b', 'a', 'c'])
|
||||
act(() => { restored.store.actions.setArchivedFilter('only') })
|
||||
await waitFor(() => { expect(names()).toEqual(['kept-archive', 'missing-archive']) })
|
||||
},
|
||||
)
|
||||
|
||||
it('does not replace a saved flat order when the archived-only view is empty', () => {
|
||||
const preferences = createWorkspaceViewStore().create()
|
||||
preferences.actions.setGroupBy('flat')
|
||||
preferences.actions.setSessionOrder(FLAT_SESSION_ORDER_KEY, ['c', 'a', 'b'], {})
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('a', 3), summary('b', 2), summary('c', 1)])),
|
||||
})
|
||||
act(() => { b.store.actions.setArchivedFilter('only') })
|
||||
expect(screen.queryByRole('treeitem')).toBeNull()
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['c', 'a', 'b'])
|
||||
act(() => { b.store.actions.setArchivedFilter('default') })
|
||||
expect(screen.getAllByRole('treeitem').map(row => row.querySelector('[class*="title"]')?.textContent))
|
||||
.toEqual(['c', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('surfaces pin and unpin rejections as toasts', async () => {
|
||||
const pinSession = vi.fn(async () => { throw new Error('pin wire down') })
|
||||
const unpinSession = vi.fn(async () => { throw new Error('unpin wire down') })
|
||||
@@ -916,6 +1067,42 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByRole('menuitem', { name: '显示已归档' })).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['show', 'only'] as const)('does not open an archived row in the %s filter', (filter) => {
|
||||
const open = vi.fn()
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('gone', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['gone'])], [sid('gone')])),
|
||||
open,
|
||||
})
|
||||
act(() => { b.store.actions.setArchivedFilter(filter) })
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByText('gone'))
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert').textContent).toBe('已归档对话暂时无法查看,请取消归档后查看')
|
||||
expect(screen.getByText('gone').closest('[role="treeitem"]')?.getAttribute('aria-description'))
|
||||
.toBe('已归档对话暂时无法查看,请取消归档后查看')
|
||||
})
|
||||
|
||||
it('does not open an archived search result or clear its query', async () => {
|
||||
const open = vi.fn()
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('gone', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['gone'])], [sid('gone')])),
|
||||
open,
|
||||
})
|
||||
act(() => { b.store.actions.setArchivedFilter('show') })
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
const input = screen.getByPlaceholderText('搜索会话名称')
|
||||
fireEvent.change(input, { target: { value: 'gone' } })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const row = within(screen.getByRole('tree', { name: '搜索结果' })).getByRole('treeitem')
|
||||
fireEvent.click(row)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert').textContent).toBe('已归档对话暂时无法查看,请取消归档后查看')
|
||||
expect(row.getAttribute('aria-description')).toBe('已归档对话暂时无法查看,请取消归档后查看')
|
||||
expect((input as HTMLInputElement).value).toBe('gone')
|
||||
})
|
||||
|
||||
it('offers unarchive on archived search results only', async () => {
|
||||
const unarchiveSession = vi.fn(async () => {})
|
||||
mount({
|
||||
@@ -1654,12 +1841,10 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one', 'three'])
|
||||
})
|
||||
|
||||
it('drags a pinned row within the pinned block, rewriting only pinned slots', () => {
|
||||
it('drags a pinned row by moving its position in the complete Session sequence', () => {
|
||||
const sessions = sessionState([
|
||||
summary('one', 4), summary('two', 3), summary('three', 2), summary('four', 1),
|
||||
])
|
||||
// Interleaved manual slots: pinned rows front in render while unpinned
|
||||
// members keep their account positions when the pinned block reorders.
|
||||
createWorkspaceViewStore().create().actions.setSessionOrder('alpha', ['three', 'one', 'four', 'two'], {})
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
@@ -1680,7 +1865,7 @@ describe('WorkspaceBrowser', () => {
|
||||
fireEvent.dragStart(two, { dataTransfer: dragData() })
|
||||
fireDrag(one, 'dragOver', 105)
|
||||
fireDrag(one, 'drop', 105)
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'two', 'four', 'one'])
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'two', 'one', 'four'])
|
||||
expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
|
||||
expect.stringContaining('two'), expect.stringContaining('one'),
|
||||
expect.stringContaining('three'), expect.stringContaining('four'),
|
||||
@@ -1695,7 +1880,7 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
fireEvent.dragStart(oneAgain, { dataTransfer: dragData() })
|
||||
fireDrag(twoAgain, 'drop', 130)
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'two', 'four', 'one'])
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'two', 'one', 'four'])
|
||||
})
|
||||
|
||||
it('keeps pinned and unpinned rows in separate drag domains', () => {
|
||||
@@ -1773,7 +1958,7 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(before)
|
||||
})
|
||||
|
||||
it('drags pinned rows within the flat pinned block and keeps cross-block targets inert', async () => {
|
||||
it('drags pinned rows within the flat pinned block and keeps cross-block targets inert', () => {
|
||||
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
@@ -1781,10 +1966,7 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
|
||||
.toEqual(['one', 'two', 'three'])
|
||||
})
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toBeUndefined()
|
||||
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
|
||||
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
|
||||
for (const row of [one, two]) {
|
||||
@@ -1872,7 +2054,7 @@ describe('WorkspaceBrowser', () => {
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
})
|
||||
fireDrag(two, 'drop', 155)
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two'])
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => {
|
||||
@@ -1891,7 +2073,7 @@ describe('WorkspaceBrowser', () => {
|
||||
fireEvent.dragEnd(one)
|
||||
// The drag ended: rows no longer accept drops.
|
||||
fireDrag(two, 'drop', 180)
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['one', 'two'])
|
||||
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toBeUndefined()
|
||||
|
||||
// Bottom half of the last row: append (anchor omitted).
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
|
||||
@@ -39,7 +39,7 @@ const sessions: SessionListState = {
|
||||
}
|
||||
const noPendingInteraction: SessionStatusSnapshot = new Map()
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({
|
||||
items, archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null,
|
||||
items, archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
function anchor(): { current: HTMLElement } {
|
||||
const element = document.createElement('button')
|
||||
|
||||
@@ -88,7 +88,7 @@ function workspaceState(
|
||||
return {
|
||||
items,
|
||||
archivedSessionIds,
|
||||
pinnedSessions: [],
|
||||
pinnedSessionIds: [],
|
||||
phase,
|
||||
state: phase === 'ready' ? 'idle' : 'loading',
|
||||
error: null,
|
||||
|
||||
@@ -3287,6 +3287,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [{ name: 'request', description: 'Session identity to unarchive.' }],
|
||||
returns: 'the complete resulting archive set.',
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'pinSession\') pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue>',
|
||||
description: 'Surface one known unarchived Session ahead of unpinned Sessions.',
|
||||
parameters: [{ name: 'request', description: 'Session identity to pin.' }],
|
||||
returns: 'the complete resulting pin set, most recently pinned first.',
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'unpinSession\') unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue>',
|
||||
description: 'Remove one Session\'s pin without changing its saved Session order.',
|
||||
parameters: [{ name: 'request', description: 'Session identity to unpin.' }],
|
||||
returns: 'the complete resulting pin set, most recently pinned first.',
|
||||
},
|
||||
{
|
||||
signature: '@Remote({ mode: \'stream\' }) follow(signal: AbortSignal): AsyncIterable<WorkspaceFollowFrame>',
|
||||
description: 'Stream a complete Workspace baseline followed by ordered increments.',
|
||||
@@ -3382,7 +3394,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'archiveSession(sessionId: SessionId): Promise<void>',
|
||||
description: 'Archive one session durably. The session must exist (live or in session persistence); its workspace accounting — or lack of one — is irrelevant. An already archived id resolves without writing.',
|
||||
description: 'Archive one session durably. The session must exist (live or in session persistence); its workspace accounting — or lack of one — is irrelevant. Archiving drops the session\'s pin in the same durable write (pinning and archival are mutually exclusive). An already archived id resolves without writing.',
|
||||
parameters: [{ name: 'sessionId', description: 'The session to archive.' }],
|
||||
returns: 'resolution after durability.',
|
||||
},
|
||||
@@ -3392,6 +3404,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [{ name: 'sessionId', description: 'The session to unarchive.' }],
|
||||
returns: 'resolution after durability.',
|
||||
},
|
||||
{
|
||||
signature: 'pinSession(sessionId: SessionId): Promise<void>',
|
||||
description: 'Pin one session durably, prepending it to the registry-global pin set. The session must exist (live or in session persistence) and must not be archived. An already pinned id resolves without writing or reordering.',
|
||||
parameters: [{ name: 'sessionId', description: 'The session to pin.' }],
|
||||
returns: 'resolution after durability.',
|
||||
},
|
||||
{
|
||||
signature: 'unpinSession(sessionId: SessionId): Promise<void>',
|
||||
description: 'Unpin one session durably by dropping it from the registry-global pin set. Unpinning runs no session-existence check because removing an id cannot introduce an unknown one, so an entry whose session is gone still resolves. An id that is not pinned resolves without writing.',
|
||||
parameters: [{ name: 'sessionId', description: 'The session to unpin.' }],
|
||||
returns: 'resolution after durability.',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
|
||||
description: 'Resolve by canonical directory path without creating or mutating a workspace. A missing path rejects during `realpath`; an existing unowned directory returns `undefined`.',
|
||||
@@ -7142,7 +7166,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'WorkspaceBaseline',
|
||||
declaration: 'export interface WorkspaceBaseline {\n readonly items: readonly WorkspaceView[];\n readonly archivedSessionIds: readonly SessionId[];\n}',
|
||||
declaration: 'export interface WorkspaceBaseline {\n readonly items: readonly WorkspaceView[];\n readonly archivedSessionIds: readonly SessionId[];\n readonly pinnedSessionIds: readonly SessionId[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'WorkspaceByteRange',
|
||||
@@ -7222,7 +7246,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'WorkspaceFollowIncrement',
|
||||
declaration: 'export type WorkspaceFollowIncrement = {\n readonly type: \'upsert\';\n readonly workspace: WorkspaceView;\n} | {\n readonly type: \'remove\';\n readonly workspaceId: WorkspaceId;\n} | {\n readonly type: \'order\';\n readonly workspaceIds: readonly WorkspaceId[];\n} | {\n readonly type: \'archived\';\n readonly archivedSessionIds: readonly SessionId[];\n};',
|
||||
declaration: 'export type WorkspaceFollowIncrement = {\n readonly type: \'upsert\';\n readonly workspace: WorkspaceView;\n} | {\n readonly type: \'remove\';\n readonly workspaceId: WorkspaceId;\n} | {\n readonly type: \'order\';\n readonly workspaceIds: readonly WorkspaceId[];\n} | {\n readonly type: \'archived\';\n readonly archivedSessionIds: readonly SessionId[];\n} | {\n readonly type: \'pinned\';\n readonly pinnedSessionIds: readonly SessionId[];\n};',
|
||||
},
|
||||
{
|
||||
name: 'WorkspaceInsertBeforeRequest',
|
||||
@@ -7236,6 +7260,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WorkspaceOrderValue',
|
||||
declaration: 'export interface WorkspaceOrderValue {\n readonly workspaceIds: readonly WorkspaceId[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'WorkspacePinSessionRequest',
|
||||
declaration: 'export interface WorkspacePinSessionRequest {\n readonly sessionId: SessionId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WorkspacePinValue',
|
||||
declaration: 'export interface WorkspacePinValue {\n readonly pinnedSessionIds: readonly SessionId[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'WorkspaceRenameRequest',
|
||||
declaration: 'export interface WorkspaceRenameRequest {\n readonly workspaceId: WorkspaceId;\n readonly title: string;\n}',
|
||||
@@ -7244,6 +7276,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WorkspaceUnarchiveSessionRequest',
|
||||
declaration: 'export interface WorkspaceUnarchiveSessionRequest {\n readonly sessionId: SessionId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WorkspaceUnpinSessionRequest',
|
||||
declaration: 'export interface WorkspaceUnpinSessionRequest {\n readonly sessionId: SessionId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WorkspaceValue',
|
||||
declaration: 'export interface WorkspaceValue {\n readonly workspace: WorkspaceView;\n}',
|
||||
|
||||
@@ -42,6 +42,6 @@ export const remoteDefaultResponses: RemoteTable = {
|
||||
// api-session-controller client `apply`: the control stream's opening baseline, then open.
|
||||
'session/control': openStream([{ type: 'baseline', value: { jobs: {}, projections: {} } }]),
|
||||
// api-workspace-controller client `apply`: the follow stream's opening baseline, then open.
|
||||
'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessions: [] } }]),
|
||||
'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] } }]),
|
||||
},
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export function workspaceSnapshot(): WorkspaceSnapshot {
|
||||
return {
|
||||
items: [],
|
||||
archivedSessionIds: [],
|
||||
pinnedSessions: [],
|
||||
pinnedSessionIds: [],
|
||||
state: 'idle',
|
||||
phase: 'ready',
|
||||
error: null,
|
||||
|
||||
@@ -173,9 +173,9 @@ export class TestWorkspaces implements IWorkspaces {
|
||||
return
|
||||
}
|
||||
await this.update((draft) => {
|
||||
draft.pinnedSessions = [
|
||||
{ sessionId, pinnedAt: Date.now() },
|
||||
...draft.pinnedSessions.filter(entry => entry.sessionId !== sessionId),
|
||||
draft.pinnedSessionIds = [
|
||||
sessionId,
|
||||
...draft.pinnedSessionIds.filter(id => id !== sessionId),
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -193,7 +193,7 @@ export class TestWorkspaces implements IWorkspaces {
|
||||
return
|
||||
}
|
||||
await this.update((draft) => {
|
||||
draft.pinnedSessions = draft.pinnedSessions.filter(entry => entry.sessionId !== sessionId)
|
||||
draft.pinnedSessionIds = draft.pinnedSessionIds.filter(id => id !== sessionId)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -756,6 +756,41 @@ describe('fixture session face', () => {
|
||||
})
|
||||
|
||||
describe('workspaces action face', () => {
|
||||
it('records pin and unpin actions, updates the ordered set, and honors stubs', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
try {
|
||||
const ws = runtime.workspaces
|
||||
const first = 'first' as SessionId
|
||||
const second = 'second' as SessionId
|
||||
await ws.pinSession(first)
|
||||
await ws.pinSession(second)
|
||||
await ws.pinSession(first)
|
||||
expect(ws.list.getSnapshot().pinnedSessionIds).toEqual([first, second])
|
||||
await ws.unpinSession(first)
|
||||
expect(ws.list.getSnapshot().pinnedSessionIds).toEqual([second])
|
||||
|
||||
const pin = vi.fn(async () => {})
|
||||
const unpin = vi.fn(async () => {})
|
||||
ws.stub('pinSession', pin)
|
||||
ws.stub('unpinSession', unpin)
|
||||
await ws.pinSession(first)
|
||||
await ws.unpinSession(second)
|
||||
expect(pin).toHaveBeenCalledWith(first)
|
||||
expect(unpin).toHaveBeenCalledWith(second)
|
||||
expect(ws.list.getSnapshot().pinnedSessionIds).toEqual([second])
|
||||
expect(ws.calls).toEqual([
|
||||
{ method: 'pinSession', args: [first] },
|
||||
{ method: 'pinSession', args: [second] },
|
||||
{ method: 'pinSession', args: [first] },
|
||||
{ method: 'unpinSession', args: [first] },
|
||||
{ method: 'pinSession', args: [first] },
|
||||
{ method: 'unpinSession', args: [second] },
|
||||
])
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const ws = runtime.workspaces
|
||||
|
||||
@@ -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/workspace/workspace/README.md
|
||||
README.md: 8ac3bdbf0a69c031f1ddd5cdadb98f9cc4d55084
|
||||
README.zh.md: 225a24cc8fbedafe8e3ae6f64a849525eeb92067
|
||||
README.md: 51fa66d346ffc4027397d8851b0c81ea87269ded
|
||||
README.zh.md: 102308d3257ba13d3ec851adcbdb1d4c159abc14
|
||||
@@ -87,7 +87,7 @@ This section explains the design decisions behind the feature and points at the
|
||||
|
||||
### API behavior
|
||||
|
||||
The API is one small family with two owners: `WorkspaceRegistry` creates, orders, and deletes projects, manages their session accounting, and archives or restores single sessions; the `Workspace` entity exposes the display title, directory status, and the session projection. Per-method contracts live in the code, not this README — see [src/index.ts](src/index.ts) and [src/entity.ts](src/entity.ts).
|
||||
The API has two owners: `WorkspaceRegistry` creates, orders, and deletes projects, manages their Session accounting, and pins, unpins, archives, or restores Sessions; the `Workspace` entity exposes the display title, directory status, and Session projection. Pinning requires a known, unarchived Session; archiving clears its pin in the same durable write, and restoring does not restore that pin. Per-method contracts live in [src/index.ts](src/index.ts) and [src/entity.ts](src/entity.ts).
|
||||
|
||||
### Source map
|
||||
|
||||
@@ -102,7 +102,7 @@ The API is one small family with two owners: `WorkspaceRegistry` creates, orders
|
||||
|
||||
### Durable shape
|
||||
|
||||
The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, and the optional `pendingMutation` marker. Records written before `archivedSessionIds` existed parse with an empty set through the schema default. Archiving and unarchiving both rewrite only that global state, so a restore is one filtered write of the same field; unarchive runs no session-existence probe, because dropping an id from the set cannot introduce an unknown one, while archive verifies the session before adding it.
|
||||
The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, `pinnedSessionIds`, and the optional `pendingMutation` marker. Archive and pin sets contain Session id strings, default to empty, and carry no per-entry objects or timestamps; the pin array keeps the most recently pinned id first. Archiving clears the pin in the same global-state write without changing Workspace membership. Unarchive runs no session-existence probe, because dropping an id from the set cannot introduce an unknown one, while archive verifies the session before adding it.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first
|
||||
|
||||
### API 行为
|
||||
|
||||
该 API 是一个由两个所有者构成的小家族:`WorkspaceRegistry` 负责创建、排序与删除项目、管理其会话记账,以及归档或恢复单个会话;`Workspace` 实体暴露显示标题、目录状态与会话投影。各方法的精确约定在代码中,而非本 README——参见 [src/index.ts](src/index.ts) 与 [src/entity.ts](src/entity.ts)。
|
||||
该 API 由两个对象负责:`WorkspaceRegistry` 创建、排序与删除项目,管理会话记账,并置顶、取消置顶、归档或恢复会话;`Workspace` 实体暴露显示标题、目录状态与会话投影。置顶要求会话已知且未归档;归档在同一次持久化写入中清除置顶,恢复会话不会恢复置顶。各方法的精确约定见 [src/index.ts](src/index.ts) 与 [src/entity.ts](src/entity.ts)。
|
||||
|
||||
### 源码地图
|
||||
|
||||
@@ -102,7 +102,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first
|
||||
|
||||
### 持久形态
|
||||
|
||||
注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds` 与可选 `pendingMutation` 标记的全局状态。在 `archivedSessionIds` 存在之前写入的记录会通过 schema 默认值解析为空集合。归档与取消归档都只重写该全局状态,因此恢复就是对同一字段的一次过滤写入;取消归档不做会话存在性探测,因为从集合中移除 id 不可能引入未知 id,而归档会在加入前校验会话。
|
||||
注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds`、`pinnedSessionIds` 与可选 `pendingMutation` 标记的全局状态。归档与置顶集合存储会话 id 字符串,默认值为空,不包含逐项对象或时间戳;置顶数组把最近置顶的 id 放在前面。归档在同一次全局状态写入中清除置顶,但不改变 Workspace 成员关系。取消归档不做会话存在性探测,因为从集合中移除 id 不可能引入未知 id,而归档会在加入前校验会话。
|
||||
|
||||
### 生命周期
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ import type { WorkspaceEntityHost } from './entity.ts'
|
||||
export { WorkspaceMoveInvalidError } from './entity.ts'
|
||||
import { defaultWorkspaceTitle, realpathNormalize } from './paths.ts'
|
||||
import { workspaceDomainSpec } from './spec.ts'
|
||||
import type { PinnedSession, WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
|
||||
import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts'
|
||||
|
||||
export type { Workspace } from './types.ts'
|
||||
export { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from './spec.ts'
|
||||
export type { PinnedSession, WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
|
||||
export type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
|
||||
export { realpathNormalize } from './paths.ts'
|
||||
|
||||
/** Identifies one workspace record (see `src/types.ts` for the brand rationale). */
|
||||
@@ -267,7 +267,7 @@ export class WorkspaceRegistry extends Service {
|
||||
await this.setState({
|
||||
...state,
|
||||
archivedSessionIds: [...state.archivedSessionIds, sessionId],
|
||||
pinnedSessions: state.pinnedSessions.filter(entry => entry.id !== sessionId),
|
||||
pinnedSessionIds: state.pinnedSessionIds.filter(id => id !== sessionId),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -298,19 +298,16 @@ export class WorkspaceRegistry extends Service {
|
||||
/**
|
||||
* The registry-global pin set: sessions surfaced ahead of every unpinned
|
||||
* session on grouping surfaces. Pinning never touches workspace accounting.
|
||||
* @returns the pin entries in pin order (most recently pinned first), each
|
||||
* carrying the epoch-millisecond `pinnedAt` it was pinned at.
|
||||
* @returns Session ids in pin order (most recently pinned first).
|
||||
*/
|
||||
get pinnedSessions(): readonly PinnedSession[] {
|
||||
return this.requireState().pinnedSessions
|
||||
get pinnedSessionIds(): readonly SessionId[] {
|
||||
return this.requireState().pinnedSessionIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin one session durably, prepending it to the registry-global pin set
|
||||
* with the current wall-clock time as its `pinnedAt`. The session must
|
||||
* exist (live or in session persistence) and must not be archived (pinning
|
||||
* and archival are mutually exclusive). An already pinned id resolves
|
||||
* without writing and keeps its original `pinnedAt`.
|
||||
* Pin one session durably, prepending it to the registry-global pin set.
|
||||
* The session must exist (live or in session persistence) and must not be
|
||||
* archived. An already pinned id resolves without writing or reordering.
|
||||
* @param sessionId - The session to pin.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
@@ -318,7 +315,7 @@ export class WorkspaceRegistry extends Service {
|
||||
return this.enqueueOperation(async () => {
|
||||
// The chain slot serializes against every other registry write, so this
|
||||
// check-then-write pair cannot interleave with another pin or archive.
|
||||
if (this.requireState().pinnedSessions.some(entry => entry.id === sessionId)) return
|
||||
if (this.requireState().pinnedSessionIds.includes(sessionId)) return
|
||||
if (this.requireState().archivedSessionIds.includes(sessionId)) {
|
||||
throw new WorkspaceArchivedSessionPinError(sessionId)
|
||||
}
|
||||
@@ -328,7 +325,7 @@ export class WorkspaceRegistry extends Service {
|
||||
const state = this.requireState()
|
||||
await this.setState({
|
||||
...state,
|
||||
pinnedSessions: [{ id: sessionId, pinnedAt: Date.now() }, ...state.pinnedSessions],
|
||||
pinnedSessionIds: [sessionId, ...state.pinnedSessionIds],
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -346,10 +343,10 @@ export class WorkspaceRegistry extends Service {
|
||||
// The chain slot serializes against every other registry write, so this
|
||||
// check-then-write pair cannot interleave with a concurrent pin.
|
||||
const state = this.requireState()
|
||||
if (!state.pinnedSessions.some(entry => entry.id === sessionId)) return
|
||||
if (!state.pinnedSessionIds.includes(sessionId)) return
|
||||
await this.setState({
|
||||
...state,
|
||||
pinnedSessions: state.pinnedSessions.filter(entry => entry.id !== sessionId),
|
||||
pinnedSessionIds: state.pinnedSessionIds.filter(id => id !== sessionId),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -431,7 +428,7 @@ export class WorkspaceRegistry extends Service {
|
||||
initialized: true,
|
||||
workspaceIds: [id, ...state.workspaceIds],
|
||||
archivedSessionIds: state.archivedSessionIds,
|
||||
pinnedSessions: state.pinnedSessions,
|
||||
pinnedSessionIds: state.pinnedSessionIds,
|
||||
})
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
@@ -464,7 +461,7 @@ export class WorkspaceRegistry extends Service {
|
||||
initialized: true,
|
||||
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
|
||||
archivedSessionIds: state.archivedSessionIds,
|
||||
pinnedSessions: state.pinnedSessions,
|
||||
pinnedSessionIds: state.pinnedSessionIds,
|
||||
}
|
||||
await this.setState({
|
||||
...nextState,
|
||||
@@ -522,7 +519,7 @@ export class WorkspaceRegistry extends Service {
|
||||
initialized: state.initialized,
|
||||
workspaceIds: state.workspaceIds,
|
||||
archivedSessionIds: state.archivedSessionIds,
|
||||
pinnedSessions: state.pinnedSessions,
|
||||
pinnedSessionIds: state.pinnedSessionIds,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -609,14 +606,14 @@ export class WorkspaceRegistry extends Service {
|
||||
initialized: false,
|
||||
workspaceIds,
|
||||
archivedSessionIds: state.archivedSessionIds,
|
||||
pinnedSessions: state.pinnedSessions,
|
||||
pinnedSessionIds: state.pinnedSessionIds,
|
||||
})
|
||||
}
|
||||
await this.setState({
|
||||
initialized: true,
|
||||
workspaceIds,
|
||||
archivedSessionIds: state.archivedSessionIds,
|
||||
pinnedSessions: state.pinnedSessions,
|
||||
pinnedSessionIds: state.pinnedSessionIds,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { WorkspaceId } from './types.ts'
|
||||
/** Workspace id schema at the durable boundary; branding has no runtime representation. */
|
||||
const workspaceId = z.string().transform(value => value as WorkspaceId)
|
||||
|
||||
const sessionId = z.string().transform(value => brandString<SessionId>(value))
|
||||
|
||||
/**
|
||||
* Durable shape of one workspace record. `path` is the `fs.realpath` canon
|
||||
* stamped at create; `sessionIds` is the ordered ownership account (array
|
||||
@@ -22,7 +24,7 @@ const workspaceId = z.string().transform(value => value as WorkspaceId)
|
||||
export const workspaceRecord = z.object({
|
||||
path: z.string(),
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(value => brandString<SessionId>(value))),
|
||||
sessionIds: z.array(sessionId),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
@@ -40,19 +42,6 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [
|
||||
z.object({ operation: z.literal('delete'), workspaceId }),
|
||||
])
|
||||
|
||||
/**
|
||||
* One registry-global pin entry: the session and the wall-clock epoch
|
||||
* milliseconds it was pinned at. Grouping surfaces rank a pinned row by the
|
||||
* later of `pinnedAt` and the session's own update recency.
|
||||
*/
|
||||
const pinnedSessionEntry = z.object({
|
||||
id: z.string().transform(value => brandString<SessionId>(value)),
|
||||
pinnedAt: z.number(),
|
||||
})
|
||||
|
||||
/** One stored pin entry inferred from {@link pinnedSessionEntry}. */
|
||||
export type PinnedSession = z.infer<typeof pinnedSessionEntry>
|
||||
|
||||
/**
|
||||
* Durable registry state. `initialized` distinguishes a valid empty registry
|
||||
* from one that still needs the header-only history bootstrap;
|
||||
@@ -60,7 +49,7 @@ export type PinnedSession = z.infer<typeof pinnedSessionEntry>
|
||||
* the registry-global archive set layered over workspace accounting: an
|
||||
* archived session keeps its `sessionIds` slot (unarchiving must restore the
|
||||
* position), so the set never participates in the one-owner accounting
|
||||
* invariant. `pinnedSessions` is the registry-global pin set in pin order
|
||||
* invariant. `pinnedSessionIds` is the registry-global pin set in pin order
|
||||
* (most recently pinned first); pinning and archival are mutually
|
||||
* exclusive, so archiving drops the session's pin. Both session sets are
|
||||
* defaulted so records written before the fields parse unchanged.
|
||||
@@ -68,8 +57,8 @@ export type PinnedSession = z.infer<typeof pinnedSessionEntry>
|
||||
export const workspaceDomainState = z.object({
|
||||
initialized: z.boolean(),
|
||||
workspaceIds: z.array(workspaceId),
|
||||
archivedSessionIds: z.array(z.string().transform(value => brandString<SessionId>(value))).default([]),
|
||||
pinnedSessions: z.array(pinnedSessionEntry).default([]),
|
||||
archivedSessionIds: z.array(sessionId).default([]),
|
||||
pinnedSessionIds: z.array(sessionId).default([]),
|
||||
pendingMutation: workspacePendingMutation.optional(),
|
||||
})
|
||||
|
||||
@@ -87,7 +76,7 @@ export const workspaceDomainSpec = defineDomain({
|
||||
version: 2,
|
||||
global: {
|
||||
schema: workspaceDomainState,
|
||||
initial: { initialized: false, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] },
|
||||
initial: { initialized: false, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] },
|
||||
},
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { z } from 'zod'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -14,10 +15,11 @@ import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persis
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import WorkspaceRegistry, {
|
||||
WorkspaceId,
|
||||
type workspaceDomainState,
|
||||
WorkspaceMoveInvalidError,
|
||||
WorkspaceOrderInvalidError,
|
||||
} from '../src/index.ts'
|
||||
import type { PinnedSession, WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
|
||||
import { defaultWorkspaceTitle, fullyQualifiedWorkspacePath } from '../src/paths.ts'
|
||||
|
||||
const DOMAIN_VERSION = 2
|
||||
@@ -145,12 +147,11 @@ function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:0
|
||||
}
|
||||
|
||||
/**
|
||||
* Media written before archivedSessionIds and pinnedSessions existed omit
|
||||
* Media written before archivedSessionIds and pinnedSessionIds existed omit
|
||||
* the fields; keeping the fixtures in that shape continuously proves the
|
||||
* schema defaults upgrade them.
|
||||
*/
|
||||
type StoredDomainState = Omit<WorkspaceDomainState, 'archivedSessionIds' | 'pinnedSessions'>
|
||||
& Partial<Pick<WorkspaceDomainState, 'archivedSessionIds' | 'pinnedSessions'>>
|
||||
type StoredDomainState = z.input<typeof workspaceDomainState>
|
||||
|
||||
function storedPool(
|
||||
entries: Array<[string, WorkspaceRecord]>,
|
||||
@@ -173,8 +174,6 @@ function storedState(pool: MemoryMediaPool): WorkspaceDomainState {
|
||||
return pool.media.get('workspace')!.global as WorkspaceDomainState
|
||||
}
|
||||
|
||||
const pinnedIds = (entries: readonly PinnedSession[]): string[] => entries.map(entry => String(entry.id))
|
||||
|
||||
let base: string
|
||||
const tempDirs: string[] = []
|
||||
|
||||
@@ -204,7 +203,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
|
||||
await fiber.await()
|
||||
expect(ctx.workspaceRegistry.list()).toEqual([])
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] })
|
||||
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] })
|
||||
})
|
||||
|
||||
it('bootstraps once from list headers only, in workspace/session createdAt order', async () => {
|
||||
@@ -238,7 +237,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
|
||||
initialized: true,
|
||||
workspaceIds: result.registry.list().map(workspace => workspace.id),
|
||||
archivedSessionIds: [],
|
||||
pinnedSessions: [],
|
||||
pinnedSessionIds: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -267,7 +266,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
|
||||
const second = await harness({ pool, sessions: [header('late', late, 100)] })
|
||||
expect(second.list).not.toHaveBeenCalled()
|
||||
expect(second.registry.list()).toEqual([])
|
||||
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] })
|
||||
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] })
|
||||
})
|
||||
|
||||
it('reuses partial records after a bootstrap record write fails', async () => {
|
||||
@@ -521,7 +520,7 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
await expect(result.registry.delete(workspace.id)).resolves.toBe(false)
|
||||
expect(result.registry.get(workspace.id)).toBeUndefined()
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] })
|
||||
expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] })
|
||||
expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false)
|
||||
await expect(realpath(dir)).resolves.toBe(dir)
|
||||
expect(result.list).toHaveBeenCalledTimes(1)
|
||||
@@ -565,7 +564,7 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
archivedSessionIds: [],
|
||||
pinnedSessions: [],
|
||||
pinnedSessionIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
|
||||
})
|
||||
const reregistered = await first.registry.create(dir)
|
||||
@@ -574,7 +573,7 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
initialized: true,
|
||||
workspaceIds: [reregistered.id],
|
||||
archivedSessionIds: [],
|
||||
pinnedSessions: [],
|
||||
pinnedSessionIds: [],
|
||||
})
|
||||
await first.fiber.dispose()
|
||||
|
||||
@@ -854,7 +853,7 @@ describe('header-validated membership projection', () => {
|
||||
const createRecovery = await harness({ pool: interruptedCreate })
|
||||
expect(createRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false)
|
||||
expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] })
|
||||
expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] })
|
||||
|
||||
const interruptedDelete = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
@@ -867,7 +866,7 @@ describe('header-validated membership projection', () => {
|
||||
const deleteRecovery = await harness({ pool: interruptedDelete })
|
||||
expect(deleteRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false)
|
||||
expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] })
|
||||
expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] })
|
||||
|
||||
const corruptPending = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
@@ -1041,33 +1040,31 @@ describe('registry-global session unarchive', () => {
|
||||
})
|
||||
|
||||
describe('registry-global session pin', () => {
|
||||
it('pins durably prepending with a pinnedAt stamp, idempotently skips repeats, and leaves accounting untouched', async () => {
|
||||
it('pins durably in prepend order, idempotently skips repeats, and leaves accounting untouched', async () => {
|
||||
const dir = await makeDir('pin-home')
|
||||
const result = await harness({ sessions: [header('first', dir, 100), header('second', dir, 200)] })
|
||||
const workspace = result.registry.list()[0]!
|
||||
expect(result.registry.pinnedSessions).toEqual([])
|
||||
expect(result.registry.pinnedSessionIds).toEqual([])
|
||||
|
||||
const before = Date.now()
|
||||
await result.registry.pinSession(SessionId('first'))
|
||||
expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['first'])
|
||||
const firstEntry = result.registry.pinnedSessions[0]!
|
||||
expect(firstEntry.pinnedAt).toBeGreaterThanOrEqual(before)
|
||||
expect(firstEntry.pinnedAt).toBeLessThanOrEqual(Date.now())
|
||||
expect(result.registry.pinnedSessionIds).toEqual(['first'])
|
||||
const firstEntry = result.registry.pinnedSessionIds[0]!
|
||||
expect(firstEntry).toEqual(SessionId('first'))
|
||||
// Pinning is a display-set write: the workspace account keeps the id.
|
||||
expect(workspace.sessionIds).toContain('first')
|
||||
expect(storedState(result.pool).pinnedSessions).toEqual([firstEntry])
|
||||
expect(storedState(result.pool).pinnedSessionIds).toEqual([firstEntry])
|
||||
const changesAfterFirst = result.changes.filter(change => change.table === '').length
|
||||
|
||||
await result.registry.pinSession(SessionId('first'))
|
||||
// The idempotent repeat keeps the original pinnedAt, never rewrites the
|
||||
// medium, and emits no change.
|
||||
expect(result.registry.pinnedSessions).toEqual([firstEntry])
|
||||
expect(result.registry.pinnedSessionIds).toEqual([firstEntry])
|
||||
expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst)
|
||||
|
||||
// The most recent pin lands first (display order).
|
||||
await result.registry.pinSession(SessionId('second'))
|
||||
expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['second', 'first'])
|
||||
expect(result.registry.pinnedSessions[0]!.pinnedAt).toBeGreaterThanOrEqual(firstEntry.pinnedAt)
|
||||
expect(result.registry.pinnedSessionIds).toEqual(['second', 'first'])
|
||||
expect(storedState(result.pool).pinnedSessionIds).toEqual([
|
||||
SessionId('second'), SessionId('first'),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects pinning archived and unknown sessions without writing', async () => {
|
||||
@@ -1079,7 +1076,7 @@ describe('registry-global session pin', () => {
|
||||
.rejects.toThrow(/cannot pin session 'stored': the session is archived/)
|
||||
await expect(result.registry.pinSession(SessionId('ghost')))
|
||||
.rejects.toThrow(/cannot pin session 'ghost'/)
|
||||
expect(storedState(result.pool).pinnedSessions).toEqual([])
|
||||
expect(storedState(result.pool).pinnedSessionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('propagates a persistence-listing failure instead of reporting an unknown session', async () => {
|
||||
@@ -1089,7 +1086,7 @@ describe('registry-global session pin', () => {
|
||||
// which the API layer would misreport as session-not-found.
|
||||
await expect(result.registry.pinSession(SessionId('unlisted')))
|
||||
.rejects.toThrow(/persistence backend down/)
|
||||
expect(storedState(result.pool).pinnedSessions).toEqual([])
|
||||
expect(storedState(result.pool).pinnedSessionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('archiving drops the pin in the same durable write', async () => {
|
||||
@@ -1099,10 +1096,10 @@ describe('registry-global session pin', () => {
|
||||
await result.registry.pinSession(SessionId('s2'))
|
||||
|
||||
await result.registry.archiveSession(SessionId('s1'))
|
||||
expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['s2'])
|
||||
expect(result.registry.pinnedSessionIds).toEqual(['s2'])
|
||||
expect(result.registry.archivedSessionIds).toEqual(['s1'])
|
||||
const stored = storedState(result.pool)
|
||||
expect(pinnedIds(stored.pinnedSessions)).toEqual(['s2'])
|
||||
expect(stored.pinnedSessionIds).toEqual(['s2'])
|
||||
expect(stored.archivedSessionIds).toEqual(['s1'])
|
||||
})
|
||||
|
||||
@@ -1112,13 +1109,13 @@ describe('registry-global session pin', () => {
|
||||
const sessions = [header('kept', dir, 100)]
|
||||
const first = await harness({ pool, sessions })
|
||||
await first.registry.pinSession(SessionId('kept'))
|
||||
const pinned = [...first.registry.pinnedSessions]
|
||||
const pinned = [...first.registry.pinnedSessionIds]
|
||||
await first.fiber.dispose()
|
||||
|
||||
const second = await harness({ pool, sessions })
|
||||
// The stored entry survives verbatim, pinnedAt included.
|
||||
expect(second.registry.pinnedSessions).toEqual(pinned)
|
||||
expect(second.registry.pinnedSessionIds).toEqual(pinned)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('registry-global session unpin', () => {
|
||||
@@ -1130,16 +1127,16 @@ describe('registry-global session unpin', () => {
|
||||
await result.registry.pinSession(SessionId('one'))
|
||||
await result.registry.pinSession(SessionId('two'))
|
||||
await result.registry.pinSession(SessionId('three'))
|
||||
expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['three', 'two', 'one'])
|
||||
expect(result.registry.pinnedSessionIds).toEqual(['three', 'two', 'one'])
|
||||
|
||||
await result.registry.unpinSession(SessionId('two'))
|
||||
// Removal keeps the survivors in pin order.
|
||||
expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['three', 'one'])
|
||||
expect(pinnedIds(storedState(result.pool).pinnedSessions)).toEqual(['three', 'one'])
|
||||
expect(result.registry.pinnedSessionIds).toEqual(['three', 'one'])
|
||||
expect(storedState(result.pool).pinnedSessionIds).toEqual(['three', 'one'])
|
||||
const changesAfterFirst = result.changes.filter(change => change.table === '').length
|
||||
|
||||
await result.registry.unpinSession(SessionId('two'))
|
||||
expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['three', 'one'])
|
||||
expect(result.registry.pinnedSessionIds).toEqual(['three', 'one'])
|
||||
// The absent-id repeat neither rewrites the medium nor emits a change.
|
||||
expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst)
|
||||
})
|
||||
@@ -1155,7 +1152,7 @@ describe('registry-global session unpin', () => {
|
||||
// Removing an id cannot introduce an unknown one, so the pin entry
|
||||
// resolves even though no session backs it and no listing runs.
|
||||
await expect(result.registry.unpinSession(SessionId('vanished'))).resolves.toBeUndefined()
|
||||
expect(result.registry.pinnedSessions).toEqual([])
|
||||
expect(result.registry.pinnedSessionIds).toEqual([])
|
||||
expect(result.list.mock.calls.length).toBe(listingsBefore)
|
||||
})
|
||||
})
|
||||
@@ -686,8 +686,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
WorkspaceInsertBeforeRequest: 'workspace.md',
|
||||
WorkspaceInsertSessionBeforeRequest: 'workspace.md',
|
||||
WorkspaceOrderValue: 'workspace.md',
|
||||
WorkspacePinSessionRequest: 'workspace.md',
|
||||
WorkspacePinValue: 'workspace.md',
|
||||
WorkspaceRenameRequest: 'workspace.md',
|
||||
WorkspaceUnarchiveSessionRequest: 'workspace.md',
|
||||
WorkspaceUnpinSessionRequest: 'workspace.md',
|
||||
WorkspaceValue: 'workspace.md',
|
||||
ClientArtifactBaseline: 'client-modules.md',
|
||||
WebBootGraph: 'client-modules.md',
|
||||
|
||||
Reference in new issue
Block a user