diff --git a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml b/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml deleted file mode 100644 index 38e3528468..0000000000 --- a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml +++ /dev/null @@ -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 diff --git a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md b/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md deleted file mode 100644 index 3b1e364e3a..0000000000 --- a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md +++ /dev/null @@ -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. diff --git a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md b/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md deleted file mode 100644 index 61e108dd50..0000000000 --- a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md +++ /dev/null @@ -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 增量与被预测的恢复界面的冻结记录。 diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml index 0d76afbcd1..2d995ebcf4 100644 --- a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md index 58775e067c..eb0c82ba84 100644 --- a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md index db17183c17..d971c7b4ff 100644 --- a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md @@ -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 增量的冻结记录。 diff --git a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml index 1fe1deceac..6895faecfb 100644 --- a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md index e8f304110c..b967e332f2 100644 --- a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md +++ b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md @@ -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. diff --git a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md index 60f2da36c2..87c2bb9f26 100644 --- a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md @@ -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 流正在重连,手动排序也会持久化已观察到的空白行位置,并保留其他所有已保存成员,直到完整基线允许对账。若等待基线后才记录该位置,首条提示词先到达时就会丢失它。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。 diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 36eab3cb42..913b082ed3 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -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), }, }) }) diff --git a/apps/web/tests/fixtures/assembled-remote.fixture.json b/apps/web/tests/fixtures/assembled-remote.fixture.json index 34d3112dfe..e147b1f192 100644 --- a/apps/web/tests/fixtures/assembled-remote.fixture.json +++ b/apps/web/tests/fixtures/assembled-remote.fixture.json @@ -253,7 +253,8 @@ "updatedAt": "2026-09-13T03:02:55.884Z" } ], - "archivedSessionIds": [] + "archivedSessionIds": [], + "pinnedSessionIds": [] } }, "control": { diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 2594960acb..0dfc4fa69c 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-client.md -web-client.md: 59bf663864b367382aedb1352ee3ca38f3e651ba -web-client.zh.md: f38ef5e2c9d2258be765d8a2bb400751d72948c8 +web-client.md: 6bcf9bf84ed0b049b801c38d2426074d784e8de6 +web-client.zh.md: 9c697964a0f92bc646f5ca305fc3372bebcdc902 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 59bf663864..6bcf9bf84e 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -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. diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index f38ef5e2c9..9c697964a0 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -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 的合并规则。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 36c4adcd78..db7bb548ef 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 8ecf54a2860ab5d442c4856f6efd03900de1ce9b -workspace.zh.md: dd4aed5f271aff0256115b03c85ec8b50ad0c502 +workspace.md: 9fe5f2bf4c62ba7d4f878b7b2f34490f43ca0b81 +workspace.zh.md: 63681e1a459c81a327654cfafa1f5cb2f231d6c8 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 8ecf54a286..9fe5f2bf4c 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -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 +/** + * 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 + +/** + * 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 + /** * Stream a complete Workspace baseline followed by ordered increments. * @param signal - generation cancellation. @@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise */ unarchiveSession(sessionId: SessionId): Promise +/** + * 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 + +/** + * 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 + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index dd4aed5f27..63681e1a45 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -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 +/** + * 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 + +/** + * 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 + /** * Stream a complete Workspace baseline followed by ordered increments. * @param signal - generation cancellation. @@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise */ unarchiveSession(sessionId: SessionId): Promise +/** + * 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 + +/** + * 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 + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index c5e7c8f116..cd584226a9 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md -README.md: ccf07bfb61ad005d516928ad27145148a62ddf19 -README.zh.md: b96f49e92696cef2475458f4b51c764063aa32f9 +README.md: 46556b0105bb5186cf27b56faee4127b358eac9b +README.zh.md: 9d210c10486a71772cf6c0a10480b734fa992441 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index ccf07bfb61..46556b0105 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -22,9 +22,9 @@ English | [中文](README.zh.md) ## 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. ----- diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index b96f49e926..9d210c1048 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -22,9 +22,9 @@ kind: "package-reference" ## 使用本包 -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。 ----- diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index 94fe394182..9a1af4c2e7 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -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 @@ -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: diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index bab0e2f85c..570b70aeb3 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -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() } diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index 58608215a7..00998f8519 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -73,7 +73,7 @@ export interface IWorkspaces { */ pinSession(sessionId: SessionId): Promise /** - * 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 diff --git a/packages/api/workspace-controller/src/commands.ts b/packages/api/workspace-controller/src/commands.ts index 2d922fac08..d69d269dfb 100644 --- a/packages/api/workspace-controller/src/commands.ts +++ b/packages/api/workspace-controller/src/commands.ts @@ -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 { 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 { diff --git a/packages/api/workspace-controller/src/feed.ts b/packages/api/workspace-controller/src/feed.ts index ef931ac7d3..709f17fa5d 100644 --- a/packages/api/workspace-controller/src/feed.ts +++ b/packages/api/workspace-controller/src/feed.ts @@ -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() @@ -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 } diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index ff433d8414..deb547e791 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -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. */ diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index 41dfc29a87..f2b78e024b 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -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 = diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index 74b1f45285..b126805183 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -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> = request => - Promise.resolve(remoteOk({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] })) + Promise.resolve(remoteOk({ pinnedSessionIds: [request.sessionId] })) onUnpinSession: ( _request: WorkspaceUnpinSessionRequest, ) => Promise> = () => - Promise.resolve(remoteOk({ pinnedSessions: [] })) + Promise.resolve(remoteOk({ pinnedSessionIds: [] })) create(request: WorkspaceCreateRequest): Promise> { 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>() 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 () => { diff --git a/packages/api/workspace-controller/tests/remote/workspace.client.ts b/packages/api/workspace-controller/tests/remote/workspace.client.ts index e65fdec1df..6857e8e31d 100644 --- a/packages/api/workspace-controller/tests/remote/workspace.client.ts +++ b/packages/api/workspace-controller/tests/remote/workspace.client.ts @@ -69,7 +69,7 @@ export function workspace(id: string, overrides: Partial = {}): 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 => ok({ archivedSessionIds: [request.sessionId] }), 'workspace/unarchiveSession': (_request: WorkspaceUnarchiveSessionRequest): RemoteResult => ok({ archivedSessionIds: [] }), - 'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult => ok({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] }), - 'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult => ok({ pinnedSessions: [] }), + 'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult => ok({ pinnedSessionIds: [request.sessionId] }), + 'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult => ok({ pinnedSessionIds: [] }), }, } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 60c833b47f..b99406b00c 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -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() @@ -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) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 9947228f8b..722b132255 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -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({ diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index 31b763ced4..7c2a0ec626 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -25,7 +25,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index ac9e3fe127..94b511a59a 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -212,7 +212,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index c2ed0d1baa..ad2896b854 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -22,7 +22,7 @@ function emptySessions() { function emptyWorkspaces() { return bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })) } diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 5feacea703..48f7ee1f62 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -28,7 +28,7 @@ function emptySessions() { function emptyWorkspaces() { return bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })) } diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index e9dbc5a61f..74140e0946 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -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' diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 1656cc244b..6b65e8aeb0 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -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), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 5d04eb8944..44b4a48ade 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -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), diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 3c4159c806..fdf57b49c7 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -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 { diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index b7260eba83..ba7061c7a2 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -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) diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index ebdce77d83..35ee863fd4 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -31,7 +31,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 6f3f0d6a90..23a049ce13 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -33,7 +33,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 00be6482a4..6ac678d4ac 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -178,7 +178,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 561c3cb361..3e8c54f972 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -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, diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 81648e06f6..464b664eea 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -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, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 0f5b09e34a..b39d737103 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: a14dcd70ed47467e29619915921dcd25314d84ce -README.zh.md: 4886d8c546d8102e3be9883ed27ba9913e4aca81 +README.md: 2f5ccee44e9d69eec45cf23bb8c368d67974eda7 +README.zh.md: 173129d6d03ccd6d3ccf9a4f72effae612711bcb diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index a14dcd70ed..2f5ccee44e 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 4886d8c546..173129d6d0 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -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 卸载,也不会扩大列表的滚动范围。初始加载、拖拽提交、展开其余会话和视图选项变化直接完成。动画组件不使用布局观察器或轮询,也不会因仅内容更新或滚动而测量位置。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index c09aeb9923..6a19c0c9eb 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -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) }} > @@ -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) }} diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 5994d8b1f1..10224356ca 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -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={(