Merge pull request #4505 from deepseek-harness/feat/session-pin-archive

feat(workspace): 会话置顶与侧边栏归档筛选
This commit is contained in:
imccyu authored and GitHub committed 2026-09-19 13:25:57 +08:00
commit bd2247c510
161 files changed
+3734 -1650

No files matched your search

@@ -1,45 +0,0 @@
# Agent Note: Session unarchive Settings page
Status: implemented
English | [中文](2026-09-12-session-unarchive-settings-page.zh.md)
## Problem
Archiving a Session removed it from every Workspace grouping surface, and nothing brought it back. The archive set is a durable display filter, so a hidden Session kept its log, its Workspace accounting slot, and its position, but the only recovered route was editing the domain state by hand. The archived feature record predicted the restore surface as "one UI surface plus one inverse RPC"; both now exist, and the Client had no place that listed archived Sessions at all.
## Decision
`WorkspaceRegistry.unarchiveSession(sessionId)` drops one id from the registry-global `archivedSessionIds` set in a single durable `setState`, running on the same operation chain as archive, create, and delete. The idempotent check-then-write pair sits inside one chain slot, so a concurrent archive cannot interleave and a lost race resolves as a no-op. An id that is not archived resolves without writing.
Unarchiving runs no session-existence probe. Archive verifies that the Session is live or persisted because it adds a reference that must resolve; unarchive only removes an id, so it cannot introduce an unknown referent and an entry whose Session is gone still restores. `@Remote('unarchiveSession')` on `WorkspaceController` returns the complete `WorkspaceArchiveValue`, matching `archiveSession`, and `IWorkspaces.unarchiveSession` plus `UiWorkspace.unarchiveSession` carry the verb to the browser. Both verbs answer with the complete set, so `ClientWorkspaceModel` installs a reply only while it is still the latest archive-set request: a later request, or a set pushed by the follow stream, supersedes an in-flight answer and keeps the projection on the newer state.
The new Web Settings page `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` owns the surface. It registers one localized `settings.section` contribution with id `archived-sessions` at nav order 25, joins the archive set from `useWorkspaces` with the loaded Session summaries from `useSessions`, lists the newest archive first with the owning Workspace title or the ungrouped label and a relative last-activity time, filters by title or Workspace name, and offers one Unarchive action per row. A rejected write is logged as a console diagnostic and leaves the row for another attempt. An archive entry whose Session summary is missing produces no row, so the page never renders an action that cannot restore anything.
Restoring is a page whose subject is archived Sessions, and the archived Sessions themselves are hidden from every grouping surface, so no Session row can host the action. The archived-sessions page joins the Settings navigation beside General, Models, and Plugins, whose rail already carries the archive glyph this page declares in `SettingsRoot`.
The `{ type: 'archived', archivedSessionIds }` follow increment already carries the complete set, so a restore reuses it: the Remote answer installs locally, and every other Client converges through the same increment. No frame type, persisted field, or `SESSION_FORMAT_VERSION` change accompanies the verb.
## Alternatives considered
**An Unarchive action in the Session row menu.** The row menu owns Archive, but a restored Session has no visible row until it is restored, so the action would have nowhere to live in the state that needs it; a disabled entry on every row would be decoration without a subject.
**A second frame type carrying a removal delta.** The existing `archived` increment is a complete-set replacement, so a removal delta would add a redundant representation that every consumer would have to merge; the full-snapshot posture is what lets the archive echo be reused unchanged.
**Rendering an archive entry whose Session is gone as a disabled row.** Such a row could explain the gap but not restore anything, and the registry and the Remote still accept the id, so the page lists what it can act on and leaves the orphan id to a future cleanup surface.
**A Session-existence probe on unarchive, mirroring archive.** The check exists to keep added references resolvable, and a removal cannot break that invariant; probing would only turn restoring a deleted history into a failure with nothing to repair.
## Consequences
The archive set stays the only durable state a restore rewrites; the `workspace` domain version, the Session log, and the Workspace accounting slot are untouched, and a restored Session returns to its recorded position. Archive and unarchive now enforce different Session checks, a deliberate asymmetry recorded in the [Workspace registry limitations](../../../../packages/workspace/workspace/README.md#known-limitations-and-deferred-work).
The restore surface is bounded by what the page can display: an archived id whose summary is not loaded has no row and no Unarchive action even though the registry method and the Remote accept it, so the page reports an unavailable set instead of an empty archive. Restoring through automation stays available, and a future cleanup surface can address the remaining orphan entries.
## Testing
`packages/workspace/workspace/tests/workspace.spec.ts` pins the registry method: removal keeps the surviving archive order, the accounting slot stays, a repeat and a never-archived id neither rewrite the medium nor emit a change, an entry whose Session is gone resolves without a persistence listing, and the surviving set reloads across a restart. `packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` pins the verb's idempotent complete-set answer and the unchanged `archived` follow increment, and `packages/api/workspace-controller/tests/model.client.spec.ts` pins the Client model echo and its refusal to install a failed answer, plus the four stale-reply races: overlapping archive or unarchive requests settling out of order, and a follow increment or baseline arriving while a reply is in flight. `packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` pins newest-first ordering, the ungrouped label, the hidden entry whose Session is gone, the reading and empty states, search, and the console diagnostic on rejection, while `tests/browser-plugin.client.spec.tsx` pins the section registration.
## Related
- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set, the follow increment, and the predicted restore surface.
@@ -1,45 +0,0 @@
# Agent Note: Session unarchive Settings page
Status: implemented
[English](2026-09-12-session-unarchive-settings-page.md) | 中文
## 问题
归档会话会把它从每一个 Workspace 分组界面中移除,而没有任何东西能把它带回来。归档集合是持久的显示过滤器,因此被隐藏的会话保留其日志、Workspace 记账位置与所在位置,但唯一的恢复途径是手工编辑领域状态。归档功能记录把恢复界面预测为「一个 UI 界面加一个反向 RPC」;两者如今都已存在,而 Client 此前根本没有列出已归档会话的地方。
## 决策
`WorkspaceRegistry.unarchiveSession(sessionId)` 在一次持久化 `setState` 中把某个 id 从注册表全局的 `archivedSessionIds` 集合里移除,并与 archive、create、delete 跑在同一条操作链上。幂等的「先检查再写入」位于同一个链槽内,因此并发归档无法插入其间,输掉竞态的一方会解析为无操作。未被归档的 id 不写盘即解析完成。
取消归档不做会话存在性探测。归档会校验会话处于实时或已持久化状态,因为它加入的引用必须可解析;取消归档只是移除一个 id,因此不可能引入未知引用,会话已不存在的条目也仍然能恢复。`WorkspaceController` 上的 `@Remote('unarchiveSession')` 返回完整的 `WorkspaceArchiveValue`,与 `archiveSession` 一致,而 `IWorkspaces.unarchiveSession` 与 `UiWorkspace.unarchiveSession` 把该动词带到浏览器。两个动词都返回完整集合,因此 `ClientWorkspaceModel` 只在该应答仍是最新归档集合请求时安装它:更晚的请求或 follow 流推送的集合都会取代在途应答,让投影停留在更新的状态上。
新的 Web 设置页 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` 拥有该界面。它注册一个 id 为 `archived-sessions`、导航顺序为 25 的本地化 `settings.section` 贡献,把来自 `useWorkspaces` 的归档集合与来自 `useSessions` 的已加载 Session 摘要合并,按归档时间由新到旧列出,并显示为其记账的 Workspace 标题或未分组标签,以及相对最近活动时间;它按标题或 Workspace 名称过滤,并为每行提供一个取消归档操作。写入被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。缺失 Session 摘要的归档条目不产生行,因此该页面绝不会渲染无法恢复任何东西的操作。
恢复操作所在的页面以已归档会话为主题,而已归档会话本身从每一个分组界面中隐藏,因此没有任何 Session 行能承载该操作。「已归档会话」页在设置导航中与「通用」「模型」「插件」并列,而该导航轨道本就带有本页在 `SettingsRoot` 中声明的归档字形。
`{ type: 'archived', archivedSessionIds }` follow 增量本就携带完整集合,因此恢复复用它:Remote 应答在本地安装,其他每个 Client 都通过同一个增量收敛。该动词不伴随任何帧类型、持久字段或 `SESSION_FORMAT_VERSION` 变更。
## 考虑过的替代方案
**在 Session 行菜单放一个取消归档操作。** 行菜单拥有 Archive,但被恢复的会话在恢复之前没有可见的行,因此该操作在真正需要它的状态里无处安放;给每一行加一个禁用条目只是没有主体的装饰。
**新增一种携带移除增量的帧类型。** 现有的 `archived` 增量是完整集合替换,因此移除增量会增加一种每个消费方都必须合并的冗余表示;正是全快照姿态让归档回声得以原样复用。
**把会话已不存在的归档条目渲染为禁用行。** 这样的行可以解释缺口,却无法恢复任何东西,而注册表与 Remote 仍然接受该 id,因此该页面只列出它能操作的条目,把遗留 id 留给未来的清理界面。
**像归档一样,在取消归档时做会话存在性探测。** 该检查的存在是为了让新增的引用保持可解析,而移除不可能破坏这一不变式;探测只会把恢复已删除的历史变成一次无从修复的失败。
## 后果
归档集合仍是恢复唯一重写的持久状态;`workspace` 领域版本、会话日志与 Workspace 记账位置都不受影响,被恢复的会话回到其记录的位置。归档与取消归档现在执行不同的会话校验,这是刻意的不对称,并记录在 [Workspace registry 的限制](../../../../packages/workspace/workspace/README.zh.md#known-limitations-and-deferred-work)中。
恢复界面受该页面能显示的内容限制:摘要未加载的已归档 id 没有行也没有取消归档操作,尽管注册表方法与 Remote 都接受它;因此页面报告没有可恢复条目,而不是把归档集合说成空的。通过自动化恢复仍然可用,未来的清理界面可以处理剩余的遗留条目。
## 测试
`packages/workspace/workspace/tests/workspace.spec.ts` 固定注册表方法:移除后幸存的归档顺序保持不变、记账位置仍在、重复取消归档与从未归档的 id 既不重写介质也不发出变更、会话已不存在的条目无需持久化列表即可解析、幸存集合在重启后重新加载。`packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` 固定该动词幂等的完整集合应答与不变的 `archived` follow 增量,`packages/api/workspace-controller/tests/model.client.spec.ts` 固定 Client model 的回声及其拒绝安装失败应答的行为,以及四种过期应答竞态:归档或取消归档请求重叠且乱序返回,以及应答在途时到达的 follow 增量或 baseline。`packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` 固定由新到旧的排序、未分组标签、会话已不存在条目的隐藏、读取与空状态、搜索,以及被拒绝时的 console 诊断,`tests/browser-plugin.client.spec.tsx` 则固定分区注册。
## 相关
- [会话归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md)——归档集合、follow 增量与被预测的恢复界面的冻结记录。
@@ -1,6 +1,6 @@
# 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
# 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: eb0c82ba84b1ebc94b23bff4d613ec6de9565d71
2026-09-18-session-pin-and-sidebar-archive.zh.md: d971c7b4ff2b6de6a0f49c41974b7654843b29ea
@@ -0,0 +1,79 @@
# Agent Note: Session pinning and the sidebar archived filter
Status: implemented
English | [中文](2026-09-18-session-pin-and-sidebar-archive.zh.md)
## Problem
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 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`.
### 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`:
- 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. 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.
**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.
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.
@@ -0,0 +1,79 @@
# Agent Note: Session pinning and the sidebar archived filter
Status: implemented
[English](2026-09-18-session-pin-and-sidebar-archive.md) | 中文
## Problem
侧边栏会话列表无法把重要会话固定在顶部,已归档会话只能通过远离列表的设置页(`ui-settings-unarchive-sessions`)访问。用户归档一个会话之后就再也看不到它了。
显隐筛选可以隐藏会话,但不改变它的 Workspace 归属。把筛选后的行保存成所属列表的完整顺序,会丢失隐藏项的位置。单独维护可编辑的置顶顺序,也会让拖拽依赖另一份序列,而不是用户正在编辑的会话顺序。
## Decision
`dsh-workspace` registry 将全局置顶与归档集合存为会话 id 数组(`pinnedSessionIds` 和 `archivedSessionIds`),并提供持久化的 `pinSession`/`unpinSession` 操作。置顶数组把最近置顶的 id 放在前面。置顶与归档互斥:归档会在同一次持久化写入中移除该会话的置顶,置顶已归档会话则以 `WorkspaceArchivedSessionPinError` 失败。
### 完整会话顺序
每份浏览器本地排序记录包含一条完整会话序列,包括置顶和归档成员。每份序列受 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。父组件重渲染不会延长其生命周期,完全淡出后不会保留可点击操作。
- 行动画由私有 React 组件使用浏览器原生位移与透明度动画实现。组件在结构性提交前后记录行位置,不使用观察器或轮询;被移除的行在滚动列表外留下不可交互的淡出副本。初始加载、拖拽提交、展开/收起其余会话,以及分组/排序/筛选切换直接完成。
### 持久化
Workspace KV、Remote 值与 Client 快照都用会话 id 数组存储归档和置顶成员,不包含对象包装或置顶时间戳。缺失集合默认为空,不接受对象形式的置顶条目。置顶与恢复不改变 Workspace 领域版本或 Session 日志格式,也不编辑 Session 日志。
## Alternatives considered
**保留设置页的归档列表。** 同一状态出现两个入口;侧边栏筛选让已归档行显示在它们原本所在的浏览上下文中,因此该包被整体移除。
**只提供恢复操作,不提供已归档筛选。** 隐藏行无法提供操作。筛选让这些行可以被访问,因此恢复可以与 Session 列表放在一起,而不需要独立设置页。
**独立恢复增量,或在取消归档时检查存在性。** 新增帧类型会重复完整集合响应,并增加合并路径。存在性探测会把移除孤立归档 id 变成错误,尽管移除不可能引入未解析 id。因此恢复复用归档集合协议,不执行该探测。
**显示缺少 Session 摘要的归档 id。** 这些条目可以解释孤立状态,但不能提供普通对话行。侧边栏显示已加载摘要,API 仍支持直接恢复;孤立项管理不属于当前浏览器。
**单独维护可编辑的置顶序列。** 这会产生两个排序来源,也需要另一条拖拽写入路径。全局置顶数组只为缺失置顶成员提供初始位置,已有位置由完整会话序列管理。
**恢复置顶或归档之前的位置。** 让置顶独立于手动位置可以恢复置顶前的位置,但置顶本身就是一次有意的重排序。另存恢复记录增加了这套交互不需要的产品保证;隐藏归档行本身已经保留其已有位置。
**用置顶时间给置顶行排名。** 这会让最近更新不再严格表达会话活动。在该模式下,新置顶的旧会话不必排在置顶区首位;手动模式才显示置顶操作调整后的首位。
**保存每次派生或筛选后的列表。** 渲染和显隐变化不是排序命令。不完整或经过筛选的投影不能覆盖完整的已保存序列。
**为每次列表变化播放动画。** 显示隐藏行、提交拖拽或切换筛选会整体移动行,观感夸张;这些变化即时应用,而置顶跳动、归档淡出和相邻行补位仍保留动画。
## Consequences
侧边栏端到端拥有已归档可见性,设置页少了一个包。置顶集合是 registry 全局的,因此置顶在分组与排序切换后仍然保留。行动画不持有持久状态,也不引入依赖。
筛选与重排序共用一条完整成员序列,隐藏的归档项不会因拖拽或切换筛选丢失。旧排序遗漏的成员在内存中补齐,并在下一次会话顺序写入时随完整序列保存。Host 的置顶与归档成员语义保持不变。浏览器不再用置顶时间提升最近更新模式下的位置,也不自动恢复置顶前的位置。
归档 id 没有已加载 Session 摘要时,不会产生可见行或侧边栏恢复操作,尽管 API 仍能移除它。这个 UI 限制不改变注册表的幂等恢复行为。
单元测试覆盖补齐、完整序列拖拽、期间发生其他变更后的 Pin 完成、新会话优先级、归档导航保护、筛选、toast 生命周期与行动画生命周期。Workspace 和 Controller 测试覆盖置顶与归档互斥、只含身份的持久化及快照、幂等空操作恢复、缺失 Session 时不探测持久化的恢复、重启恢复,以及被新请求或推送状态取代的旧一元响应。无密钥的侧边栏恢复场景验证行操作与重载后的持久性。
## 相关
- [Session 归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md) — 归档集合及其 follow 增量的冻结记录。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md
2026-09-10-derived-workspace-recency.md: e8f304110c89bf563164dd24125beeb36e7644a6
2026-09-10-derived-workspace-recency.zh.md: 60f2da36c277e77d367f152aa8a3159ff68d909c
2026-09-10-derived-workspace-recency.md: b967e332f234d6d120bb82c444e4b05818b85103
2026-09-10-derived-workspace-recency.zh.md: 87c2bb9f2648baa613ded32cda5b8673a77c5f56
@@ -12,7 +12,7 @@ An editable activity-promoted list can disagree with its displayed timestamps wi
Last updated is a pure projection of current Session summaries: ordinary rows sort by descending `updatedAt`, with Session id as the tie-break. The selected blank New Session precedes ordinary rows until its first prompt. Grouped, Ungrouped, and flat views use the same policy. Reloads and delayed summaries require no observed-timestamp history or promotion events in the view store.
Only Manual uses browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. Reconciliation retains saved members that remain in the Workspace account, removes departed members, and appends newly known members at the end, newest first when several arrive together. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
Manual reads browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. The [Session pinning and archive rules](../feature/2026-09-18-session-pin-and-sidebar-archive.md) own missing-member supplementation and keep archive visibility out of saved membership. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
The selected blank New Session is a display invariant applied after either base order: it remains first and cannot start a drag. Manual persists the observed blank position even while the Workspace stream reconnects, preserving every other saved member until the complete baseline permits reconciliation. Waiting to record that position would lose it if the first prompt arrives before the baseline. Its first prompt removes the blank state. Manual then keeps the existing first slot and allows dragging; Last updated projects it from the prompt timestamp. The ordering subscription and reconciliation remain mounted when the sidebar is collapsed or search replaces the list body.
@@ -12,7 +12,7 @@ Status: implemented
最近更新是当前 Session 摘要的纯投影:普通行按 `updatedAt` 降序排列,时间相同时按 Session id 排序。当前选中的空白新会话在首条提示词落地前排在普通行之前。分组、Ungrouped 和单列表视图采用相同策略。重新加载和摘要迟到不需要视图存储中的已观察时间历史或提升事件。
只有手动排序使用浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。对账会保留仍属于 Workspace 记账的已保存成员、移除已经离开的成员,并把新发现的成员追加到末尾;多条同时到达时按最近更新时间降序排列。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
手动排序读取浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。[会话置顶与归档规则](../feature/2026-09-18-session-pin-and-sidebar-archive.zh.md)说明缺失成员补齐,并让归档显隐不影响保存的成员关系。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
当前选中的空白新会话是在两种基础顺序之后应用的显示不变式:它保持首位且无法发起拖拽。即使 Workspace 流正在重连,手动排序也会持久化已观察到的空白行位置,并保留其他所有已保存成员,直到完整基线允许对账。若等待基线后才记录该位置,首条提示词先到达时就会丢失它。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。
+2
View File
@@ -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),
},
})
})
+1 -1
View File
@@ -83,7 +83,7 @@ async function openSeed(page: Page): Promise<void> {
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
const search = page.getByRole('textbox', { name: 'Search session names', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await results.first().waitFor({ timeout: 60_000 })
+1 -1
View File
@@ -291,7 +291,7 @@ async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: str
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
const search = page.getByRole('textbox', { name: 'Search session names', exact: true })
// Cold summaries initially show the temporary workspace basename, so the
// persisted first-prompt marker is the stable user-facing identity. The
// query itself triggers lazy content-index reconciliation; no transient
+1 -1
View File
@@ -192,7 +192,7 @@ async function openSeededSession(page: Page): Promise<void> {
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
const search = page.getByRole('textbox', { name: 'Search session names', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "Agent 预设" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "Agent 预设" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "Agent 预设" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -1,8 +1,10 @@
- tree "Sessions":
- treeitem "{{workspace}}" [expanded]
- treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314" [selected]:
- treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314 Archive session Pin session" [selected]:
- text: Review deepseek-ai/deepseek-harness#314
- button "Session actions for Review deepseek-ai/deepseek-harness#314"
- button "Archive session"
- button "Pin session"
---
@@ -1,8 +1,10 @@
- tree "Sessions":
- treeitem "{{workspace}}" [expanded]
- treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314" [selected]:
- treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314 Archive session Pin session" [selected]:
- text: Review deepseek-ai/deepseek-harness#314
- button "Session actions for Review deepseek-ai/deepseek-harness#314"
- button "Archive session"
- button "Pin session"
---
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
@@ -5,7 +5,6 @@
- button "Models"
- button "Built-in plugins"
- button "Agent presets"
- button "Archived sessions"
- button "Open configuration file"
- button "Close"
- text: Permission Choose the default permission mode for new sessions
@@ -5,7 +5,6 @@
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "已归档会话"
- button "打开配置文件"
- button "关闭"
- text: 权限 选择新会话的默认权限模式
@@ -7,3 +7,7 @@
- text: Order by
- menuitem "Manual"
- menuitem "Last updated"
- separator
- text: Filter sessions
- menuitem "Show archived"
- menuitem "Archived only"
+2 -1
View File
@@ -253,7 +253,8 @@
"updatedAt": "2026-09-13T03:02:55.884Z"
}
],
"archivedSessionIds": []
"archivedSessionIds": [],
"pinnedSessionIds": []
}
},
"control": {
+4 -3
View File
@@ -241,9 +241,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
() => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))),
{ timeout: 5_000 },
).toEqual(['true', null, null])
await branchButtons.first().focus()
await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 })
.toBe('Available only on the last message of a completed turn')
await branchButtons.first().press('Shift+Tab')
await page.keyboard.press('Tab')
await expect.poll(() => page.getByRole('tooltip').allTextContents(), { timeout: 5_000 })
.toEqual(['Available only on the last message of a completed turn'])
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
}, 60_000)
+2 -2
View File
@@ -62,7 +62,7 @@ async function ensureSeedOpen(page: Page): Promise<void> {
// Search is a collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
const search = page.getByPlaceholder('Search session names', { exact: false })
if (await chat.count() === 0) {
await search.fill('WATERFALL')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
@@ -188,7 +188,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
// Search is a collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
const search = page.getByPlaceholder('Search session names', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
await search.fill('zzzqx-no-such-session')
+1 -1
View File
@@ -72,7 +72,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
// collapsed header action; expand it so the input is actionable.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
const search = page.getByPlaceholder('Search session names', { exact: false })
await search.fill('Run a PowerShell command')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
+1 -1
View File
@@ -53,7 +53,7 @@ describe('web e2e: rail search click survives its own document-level bubble', ()
const wideSearch = page.getByRole('button', { name: 'Search sessions' })
await expect.poll(async () => wideSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
const input = page.getByPlaceholder('Search sessions...')
const input = page.getByPlaceholder('Search session names')
await expect.poll(
async () => input.evaluate(el => document.activeElement === el),
{ timeout: FOCUS_SETTLE_MS + 10_000 },
+1 -1
View File
@@ -681,7 +681,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
expect(await catalogRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1)
await page.getByRole('button', { name: 'Search sessions' }).click()
const search = page.getByPlaceholder('Search sessions', { exact: false })
const search = page.getByPlaceholder('Search session names', { exact: false })
await search.fill(CATALOG_TITLE)
const result = page.getByRole('tree', { name: 'Search results' })
.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) })
+12 -43
View File
@@ -1,8 +1,5 @@
// Web e2e scenario: the archived-session Settings page restores a Session
// through the real wire, from its row menu through the archive set, the page,
// and the reload that rebuilds state from the host baseline. Zero model calls:
// both verbs are host RPCs and the page reads already-loaded Session summaries,
// so no replay fixture mounts and a stray model stream fails loud.
// Archive and restore through sidebar row menus and the archived filter,
// then reload from the Host baseline. This flow makes no model calls.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
@@ -19,7 +16,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
const SEED_ID = 'session-unarchive-web-e2e'
describe('web e2e: archived sessions are restored from the Settings page', () => {
describe('web e2e: archived sessions are restored from the sidebar filter', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -76,7 +73,7 @@ describe('web e2e: archived sessions are restored from the Settings page', () =>
await scaffold?.close()
})
it('archives the seed, unarchives it from Settings, and keeps it restored across reload', async () => {
it('archives the seed, restores it through the sidebar filter, and keeps it restored across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-session-unarchive'))
// Select the only visible Session, then give it a user-owned title: the
// locator binds to the seed's own copy on both sides of the round trip.
@@ -104,46 +101,18 @@ describe('web e2e: archived sessions are restored from the Settings page', () =>
expect((await scaffold.ctx.sessionPersistence.list()).map(snapshot => snapshot.header.id))
.toContain(SessionId(SEED_ID))
// The Settings page lists the archived Session behind its own search box.
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Settings' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Archived sessions' }).click()
const search = dialog.getByRole('searchbox', { name: 'Search archived sessions' })
await search.waitFor({ timeout: 10_000 })
const unarchiveRow = dialog.getByRole('button', { name: `Unarchive ${title}` })
await expect.poll(() => unarchiveRow.count(), { timeout: 10_000 }).toBe(1)
expect(await dialog.getByText(title, { exact: true }).count()).toBe(1)
expect(await dialog.getByText(/^Ungrouped · /).count()).toBe(1)
expect(await dialog.getByText('No archived sessions.', { exact: true }).count()).toBe(0)
// The search box filters these rows in both directions.
await search.fill('zzz-no-such-archived-session')
await expect.poll(
() => dialog.getByText('No matching sessions.', { exact: true }).count(),
{ timeout: 5_000 },
).toBe(1)
expect(await unarchiveRow.count()).toBe(0)
await search.fill(title)
await expect.poll(() => unarchiveRow.count(), { timeout: 5_000 }).toBe(1)
expect(await dialog.getByText('No matching sessions.', { exact: true }).count()).toBe(0)
await search.fill('')
await expect.poll(() => unarchiveRow.count(), { timeout: 5_000 }).toBe(1)
// Unarchive: the durable set empties (the row's own RPC), the page row
// leaves with the empty status, and the Session row is back in the sidebar.
await unarchiveRow.click()
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click()
await ungroupedSection()
await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(1)
await clickHoverAction(sessionRow, `Session actions for ${title}`)
await page.getByRole('menuitem', { name: 'Unarchive session' }).click()
await expect.poll(
() => [...scaffold.ctx.workspaceRegistry.archivedSessionIds],
{ timeout: 10_000 },
).toEqual([])
await expect.poll(() => unarchiveRow.count(), { timeout: 10_000 }).toBe(0)
expect(await dialog.getByText(title, { exact: true }).count()).toBe(0)
await expect.poll(
() => dialog.getByText('No archived sessions.', { exact: true }).count(),
{ timeout: 10_000 },
).toBe(1)
await dialog.getByRole('button', { name: 'Close' }).last().click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click()
await ungroupedSection()
await expect.poll(() => sessionRow.count(), { timeout: 15_000 }).toBe(1)
+2 -1
View File
@@ -595,7 +595,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
() => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(),
{ timeout: 15_000 },
).toBe(3)
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
expect(await page.getByRole('tree', { name: 'Sessions' })
.getByRole('treeitem', { name: 'Ungrouped', exact: true }).count()).toBe(0)
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1)
await compareOrRefreshGolden(
@@ -71,7 +71,7 @@ async function openSeed(page: Page): Promise<void> {
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
const search = page.getByRole('textbox', { name: 'Search session names', exact: true })
await search.fill(FIXTURE.markers.user(1))
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
+2 -2
View File
@@ -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/config-catalog.md
config-catalog.md: c439dcf876922bc01d0ac4872f2101246ee53ed1
config-catalog.zh.md: e3d76addd80421759db5c6ec896d9837a05532b4
config-catalog.md: d802c5f084072dbe4f6654aecaf0640be96fb4bf
config-catalog.zh.md: b7c3b4be06b9ddf3fcd4117483fb0b1472493441
-1
View File
@@ -3818,7 +3818,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
-1
View File
@@ -3820,7 +3820,6 @@ export interface Config {
- `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
+2 -2
View File
@@ -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/module-graph.md
module-graph.md: 2e2899c3a74e1d2c5fe50ab137408e7ed3beca82
module-graph.zh.md: 49291ba55842462e5d532cce1de4f3803610d908
module-graph.md: 90c9601b329daa91f21713a20cf499c9937e6d34
module-graph.zh.md: 19e6bd1b942ae3319f137b7528fea4b69383d0aa
-2
View File
@@ -180,7 +180,6 @@ flowchart TD
pkg_client_ui_settings_models["client-ui-settings-models"]
pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
pkg_client_ui_sidebar["client-ui-sidebar"]
pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
@@ -1362,7 +1361,6 @@ flowchart TD
| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — |
| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
-2
View File
@@ -182,7 +182,6 @@ flowchart TD
pkg_client_ui_settings_models["client-ui-settings-models"]
pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
pkg_client_ui_sidebar["client-ui-sidebar"]
pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
@@ -1364,7 +1363,6 @@ flowchart TD
| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — |
| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -47,7 +47,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe
### Workspaces
[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace mutation policy 与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器侧 row、order、archived Session id、command echo,以及 stream/unary 竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order` 和 `archived` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,而 `ui-workspace` 向 UI 提供 `useWorkspaces` 与 navigation callback。archived Session id 过滤每一个分组视图,并驱动「已归档会话」设置页;该页把该集合与已加载的 Session summary 合并,为每行提供一个取消归档操作。恢复会调用 `workspace.unarchiveSession` Remote,返回的完整集合则经 `archived` increment 到达每个 Client。
[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace 变更策略与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器行、Workspace 顺序、归档与置顶 Session id 数组、命令回显,以及流与一元调用的竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order`、`archived` 和 `pinned` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,`ui-workspace` 则提供 `useWorkspaces` 与导航回调。侧边栏的 `ArchivedFilter` 控制列表与搜索中的默认隐藏、显示已归档或仅显示已归档。归档行保留排序位置、置灰展示,必须通过行菜单或搜索结果的取消归档操作恢复后才能打开。恢复调用 `workspace.unarchiveSession`,完整归档集合通过一元响应及 `archived` increment 到达 Client。Session 显示顺序保存在浏览器本地,包含隐藏归档项;置顶在该完整顺序内移动 Session,取消置顶不恢复先前位置。
这种配对不会产生第二份业务真相。Host controller 决定持久状态与 mutation outcome;Client model 维护最新可用的本地 projection,在有利于渲染时保持 object identity,并明确 delayed response 与 replacement baseline 的合并规则。
+2 -2
View File
@@ -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
+40 -1
View File
@@ -121,6 +121,10 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session
Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`.
## Session pinning
The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspacePinSessionRequest` and `WorkspaceUnpinSessionRequest`, each carrying one `sessionId`. Both operations return `WorkspacePinValue`: the complete `pinnedSessionIds` array of Session ids, most recently pinned first. Pinning requires a known, unarchived Session; unpinning an id that is not pinned succeeds without changing the set. Archiving removes the Session's pin in the same durable write, and unarchiving does not restore it.
## Consumers
[`dsh-workspace-controller`](../../packages/api/workspace-controller) serves workspace CRUD to GUI clients over `ctx.workspaceRegistry`, and [`dsh-session-controller`](../../packages/api/session-controller) performs the create-session-then-attach flow above. [dsh-agent-instructions](../../packages/context/agent-instructions) is **not** a consumer despite the name: it discovers AGENTS.md-style instruction files under an agent's own cwd and never touches `ctx.workspaceRegistry` — the shared word refers to the user's working directory, not to this registry's entities.
@@ -339,6 +343,20 @@ Host service backing the generated `ctx.remote.workspace` namespace.
*/
@Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue>
/**
* Surface one known unarchived Session ahead of unpinned Sessions.
* @param request - Session identity to pin.
* @returns the complete resulting pin set, most recently pinned first.
*/
@Remote('pinSession') pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue>
/**
* Remove one Session's pin without changing its saved Session order.
* @param request - Session identity to unpin.
* @returns the complete resulting pin set, most recently pinned first.
*/
@Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue>
/**
* Stream a complete Workspace baseline followed by ordered increments.
* @param signal - generation cancellation.
@@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
/**
* Archive one session durably. The session must exist (live or in session
* persistence); its workspace accounting — or lack of one — is irrelevant.
* An already archived id resolves without writing.
* Archiving drops the session's pin in the same durable write (pinning and
* archival are mutually exclusive). An already archived id resolves without
* writing.
* @param sessionId - The session to archive.
* @returns resolution after durability.
*/
@@ -503,6 +523,25 @@ archiveSession(sessionId: SessionId): Promise<void>
*/
unarchiveSession(sessionId: SessionId): Promise<void>
/**
* Pin one session durably, prepending it to the registry-global pin set.
* The session must exist (live or in session persistence) and must not be
* archived. An already pinned id resolves without writing or reordering.
* @param sessionId - The session to pin.
* @returns resolution after durability.
*/
pinSession(sessionId: SessionId): Promise<void>
/**
* Unpin one session durably by dropping it from the registry-global pin
* set. Unpinning runs no session-existence check because removing an id
* cannot introduce an unknown one, so an entry whose session is gone still
* resolves. An id that is not pinned resolves without writing.
* @param sessionId - The session to unpin.
* @returns resolution after durability.
*/
unpinSession(sessionId: SessionId): Promise<void>
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned
+40 -1
View File
@@ -121,6 +121,10 @@ interface Workspace {
会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。
## 会话置顶
控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspacePinSessionRequest` 和 `WorkspaceUnpinSessionRequest`,两者都携带一个 `sessionId`。两个操作都返回 `WorkspacePinValue`:完整的会话 id 数组 `pinnedSessionIds`,最近置顶的会话排在前面。置顶要求会话已知且未归档;对未置顶的 id 取消置顶会成功,且不改变集合。归档在同一次持久化写入中移除该会话的置顶,取消归档不会恢复置顶。
## 消费方
[`dsh-workspace-controller`](../../packages/api/workspace-controller) 经 `ctx.workspaceRegistry` 向 GUI 客户端提供工作区 CRUD,[`dsh-session-controller`](../../packages/api/session-controller) 执行上文「先建会话再 attach」的流程。[dsh-agent-instructions](../../packages/context/agent-instructions) 尽管名字如此,却**不是**消费方:它在 agent 自己的 cwd 下发现 AGENTS.md 风格的指令文件,从不触碰 `ctx.workspaceRegistry`——两者共用的这个词指的是用户的工作目录,而非本注册表的实体。
@@ -339,6 +343,20 @@ Host service backing the generated `ctx.remote.workspace` namespace.
*/
@Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue>
/**
* Surface one known unarchived Session ahead of unpinned Sessions.
* @param request - Session identity to pin.
* @returns the complete resulting pin set, most recently pinned first.
*/
@Remote('pinSession') pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue>
/**
* Remove one Session's pin without changing its saved Session order.
* @param request - Session identity to unpin.
* @returns the complete resulting pin set, most recently pinned first.
*/
@Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue>
/**
* Stream a complete Workspace baseline followed by ordered increments.
* @param signal - generation cancellation.
@@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
/**
* Archive one session durably. The session must exist (live or in session
* persistence); its workspace accounting — or lack of one — is irrelevant.
* An already archived id resolves without writing.
* Archiving drops the session's pin in the same durable write (pinning and
* archival are mutually exclusive). An already archived id resolves without
* writing.
* @param sessionId - The session to archive.
* @returns resolution after durability.
*/
@@ -503,6 +523,25 @@ archiveSession(sessionId: SessionId): Promise<void>
*/
unarchiveSession(sessionId: SessionId): Promise<void>
/**
* Pin one session durably, prepending it to the registry-global pin set.
* The session must exist (live or in session persistence) and must not be
* archived. An already pinned id resolves without writing or reordering.
* @param sessionId - The session to pin.
* @returns resolution after durability.
*/
pinSession(sessionId: SessionId): Promise<void>
/**
* Unpin one session durably by dropping it from the registry-global pin
* set. Unpinning runs no session-existence check because removing an id
* cannot introduce an unknown one, so an entry whose session is gone still
* resolves. An id that is not pinned resolves without writing.
* @param sessionId - The session to unpin.
* @returns resolution after durability.
*/
unpinSession(sessionId: SessionId): Promise<void>
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md
README.md: ccf07bfb61ad005d516928ad27145148a62ddf19
README.zh.md: b96f49e92696cef2475458f4b51c764063aa32f9
README.md: 46556b0105bb5186cf27b56faee4127b358eac9b
README.zh.md: 9d210c10486a71772cf6c0a10480b734fa992441
+2 -2
View File
@@ -22,9 +22,9 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable `workspace/*` or `directory-picker/*` code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected.
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected.
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived Session ids, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner.
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner.
-----
@@ -22,9 +22,9 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定 `workspace/*` 或 `directory-picker/*` 错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、已归档 Session id、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。
-----
@@ -107,6 +107,9 @@ function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncr
case 'archived':
accept.replaceArchived(frame.archivedSessionIds)
return
case 'pinned':
accept.replacePinned(frame.pinnedSessionIds)
return
/* v8 ignore next -- the generated Remote codec validates this closed union */
default:
return assertNever(frame)
@@ -13,7 +13,10 @@ import type {
WorkspaceDeleteValue,
WorkspaceInsertSessionBeforeRequest,
WorkspaceOrderValue,
WorkspacePinSessionRequest,
WorkspacePinValue,
WorkspaceUnarchiveSessionRequest,
WorkspaceUnpinSessionRequest,
WorkspaceValue,
WorkspaceId,
WorkspaceView,
@@ -30,6 +33,8 @@ export interface WorkspaceSnapshot {
readonly items: readonly WorkspaceView[]
/** Complete registry-global archive set in Host order. */
readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']
/** Complete registry-global pin set, most recently pinned first. */
readonly pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']
readonly state: 'idle' | 'loading' | 'error'
readonly phase: WorkspaceListPhase
readonly error: RemoteFailure | null
@@ -47,6 +52,8 @@ export interface WorkspaceFollowSink {
replaceOrder(workspaceIds: readonly WorkspaceId[]): void
/** Replace the complete archived Session set. */
replaceArchived(sessionIds: WorkspaceArchiveValue['archivedSessionIds']): void
/** Replace the complete pinned Session set. */
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void
}
/**
@@ -55,6 +62,7 @@ export interface WorkspaceFollowSink {
export class ClientWorkspaceModel implements WorkspaceFollowSink {
private items: readonly WorkspaceView[] = []
private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = []
private pinnedSessionIds: WorkspacePinValue['pinnedSessionIds'] = []
private state: WorkspaceSnapshot['state'] = 'loading'
private phase: WorkspaceListPhase = 'pending'
private error: RemoteFailure | null = null
@@ -66,6 +74,8 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
private committedOrder: WorkspaceId[] = []
/** Latest archive-set request; a later request or a pushed set supersedes it. */
private archiveRequestSeq = 0
/** Latest pin-set request; a later request or a pushed set supersedes it. */
private pinRequestSeq = 0
/** Host Workspace ids are never reused, so delayed data cannot resurrect a removed row. */
private readonly removedIds = new Set<WorkspaceId>()
private readonly listeners = new Set<() => void>()
@@ -173,6 +183,9 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
const result = await this.remote.archiveSession({ sessionId })
if (result.ok && requestSeq === this.archiveRequestSeq) {
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.pinnedSessionIds.filter(id => id !== sessionId))
}
return result
}
@@ -194,6 +207,40 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
return result
}
/**
* Pin one Session and install the returned complete pin set.
* A reply superseded by a later pin request or a pushed set installs nothing.
* @param sessionId - Session to pin.
* @returns generated Remote result.
*/
async pinSession(
sessionId: WorkspacePinSessionRequest['sessionId'],
): Promise<RemoteResult<WorkspacePinValue>> {
const requestSeq = ++this.pinRequestSeq
const result = await this.remote.pinSession({ sessionId })
if (result.ok && requestSeq === this.pinRequestSeq) {
this.installPinned(result.value.pinnedSessionIds)
}
return result
}
/**
* Unpin one Session and install the returned complete pin set.
* A reply superseded by a later pin request or a pushed set installs nothing.
* @param sessionId - Session to unpin.
* @returns generated Remote result.
*/
async unpinSession(
sessionId: WorkspaceUnpinSessionRequest['sessionId'],
): Promise<RemoteResult<WorkspacePinValue>> {
const requestSeq = ++this.pinRequestSeq
const result = await this.remote.unpinSession({ sessionId })
if (result.ok && requestSeq === this.pinRequestSeq) {
this.installPinned(result.value.pinnedSessionIds)
}
return result
}
/**
* Replace the projection from one complete stream-generation baseline.
* @param baseline - complete Workspace and archive projection.
@@ -201,8 +248,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
replaceBaseline(baseline: WorkspaceBaseline): void {
this.orderFrameGeneration++
this.archiveRequestSeq++
this.pinRequestSeq++
this.installViews(baseline.items)
this.installArchived(baseline.archivedSessionIds)
this.installPinned(baseline.pinnedSessionIds)
this.state = 'idle'
this.phase = 'ready'
this.error = null
@@ -234,6 +283,15 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
this.installArchived(archivedSessionIds)
}
/**
* Replace the pinned Session set from the current follow generation.
* @param pinnedSessionIds - complete Host-confirmed pin set, most recently pinned first.
*/
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void {
this.pinRequestSeq++
this.installPinned(pinnedSessionIds)
}
/** Keep the last complete projection visible while a lost carrier reconnects. */
handleCarrierFailure(): void {
this.state = 'loading'
@@ -275,6 +333,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
return {
items: this.items,
archivedSessionIds: this.archivedSessionIds,
pinnedSessionIds: this.pinnedSessionIds,
state: this.state,
phase: this.phase,
error: this.error,
@@ -288,6 +347,13 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
this.invalidate()
}
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()
}
private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void {
if (committed) this.committedOrder = [...workspaceIds]
const rank = new Map(workspaceIds.map((id, index) => [id, index]))
@@ -67,6 +67,16 @@ export interface IWorkspaces {
* @param sessionId - Session to unarchive.
*/
unarchiveSession(sessionId: SessionId): Promise<void>
/**
* Pin a Session ahead of unpinned Sessions on Workspace grouping surfaces.
* @param sessionId - Session to pin.
*/
pinSession(sessionId: SessionId): Promise<void>
/**
* Remove a Session's pin without changing its saved Session order.
* @param sessionId - Session to unpin.
*/
unpinSession(sessionId: SessionId): Promise<void>
/**
* Move a Session within one Workspace account.
* @param workspaceId - owning Workspace.
@@ -126,6 +136,16 @@ export class WorkspaceController extends Service implements IWorkspaces {
if (!result.ok) throw commandError('session unarchive', result.error)
}
async pinSession(sessionId: SessionId): Promise<void> {
const result = await this.model.pinSession(sessionId)
if (!result.ok) throw commandError('session pin', result.error)
}
async unpinSession(sessionId: SessionId): Promise<void> {
const result = await this.model.unpinSession(sessionId)
if (!result.ok) throw commandError('session unpin', result.error)
}
async insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
@@ -3,6 +3,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import {
WorkspaceArchivedSessionPinError,
WorkspaceId,
WorkspaceMoveInvalidError,
WorkspaceOrderInvalidError,
@@ -20,8 +21,11 @@ import type {
WorkspaceInsertBeforeRequest,
WorkspaceInsertSessionBeforeRequest,
WorkspaceOrderValue,
WorkspacePinSessionRequest,
WorkspacePinValue,
WorkspaceRenameRequest,
WorkspaceUnarchiveSessionRequest,
WorkspaceUnpinSessionRequest,
WorkspaceValue,
} from './types.ts'
@@ -173,6 +177,38 @@ export class WorkspaceCommands {
return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
}
/**
* Add one known unarchived Session to the registry-global pin set.
* @param request - Session identity to pin.
* @returns the complete resulting pin set, most recently pinned first.
*/
async pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue> {
try {
await this.ctx.workspaceRegistry.pinSession(request.sessionId)
} catch (error) {
if (error instanceof WorkspaceUnknownSessionError) {
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error })
}
if (error instanceof WorkspaceArchivedSessionPinError) {
throw new RemoteError('gateway/bad-request', error.message, {}, { cause: error })
}
throw error
}
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
}
/**
* Drop one Session from the registry-global pin set. An id that is not
* pinned is not an error: the call is idempotent, so a lost race with
* another surface resolves as a no-op.
* @param request - Session identity to unpin.
* @returns the complete resulting pin set, most recently pinned first.
*/
async unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue> {
await this.ctx.workspaceRegistry.unpinSession(request.sessionId)
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
}
private requireWorkspace(workspaceId: WorkspaceId): Workspace {
const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId))
if (workspace === undefined) throw workspaceNotFound(workspaceId)
@@ -49,6 +49,7 @@ export class WorkspaceFeed {
private knownIds: Set<string>
private order: readonly string[]
private archived: readonly string[]
private pinned: readonly string[]
/** @param ctx - Host context containing the authoritative Workspace registry. */
constructor(private readonly ctx: Context) {
@@ -56,6 +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 = ctx.workspaceRegistry.pinnedSessionIds.map(String)
ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) })
ctx.effect(() => () => {
for (const follower of this.followers) follower.close()
@@ -65,12 +67,13 @@ export class WorkspaceFeed {
/**
* Read the complete current projection synchronously.
* @returns all active Workspaces and archived Session identities.
* @returns all active Workspaces plus archived and pinned Session identities.
*/
baseline(): WorkspaceBaseline {
return {
items: this.ctx.workspaceRegistry.list().map(workspaceView),
archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds],
pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds],
}
}
@@ -115,6 +118,11 @@ export class WorkspaceFeed {
this.archived = nextArchived
this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] })
}
const nextPinned = state.pinnedSessionIds.map(String)
if (!sameStrings(this.pinned, nextPinned)) {
this.pinned = nextPinned
this.publish({ type: 'pinned', pinnedSessionIds: [...state.pinnedSessionIds] })
}
return
}
if (change.table !== 'workspaces') return
@@ -16,8 +16,11 @@ import type {
WorkspaceInsertBeforeRequest,
WorkspaceInsertSessionBeforeRequest,
WorkspaceOrderValue,
WorkspacePinSessionRequest,
WorkspacePinValue,
WorkspaceRenameRequest,
WorkspaceUnarchiveSessionRequest,
WorkspaceUnpinSessionRequest,
WorkspaceValue,
} from './types.ts'
@@ -120,6 +123,26 @@ export class WorkspaceController extends TypertRemoteService {
return this.commands.unarchiveSession(request)
}
/**
* Surface one known unarchived Session ahead of unpinned Sessions.
* @param request - Session identity to pin.
* @returns the complete resulting pin set, most recently pinned first.
*/
@Remote('pinSession')
pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue> {
return this.commands.pinSession(request)
}
/**
* Remove one Session's pin without changing its saved Session order.
* @param request - Session identity to unpin.
* @returns the complete resulting pin set, most recently pinned first.
*/
@Remote('unpinSession')
unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue> {
return this.commands.unpinSession(request)
}
/**
* Stream a complete Workspace baseline followed by ordered increments.
* @param signal - generation cancellation.
@@ -114,10 +114,27 @@ export interface WorkspaceArchiveValue {
readonly archivedSessionIds: readonly SessionId[]
}
/** Session requested for pinning ahead of unpinned Sessions on grouping surfaces. */
export interface WorkspacePinSessionRequest {
readonly sessionId: SessionId
}
/** Session requested for removal from the pin set. */
export interface WorkspaceUnpinSessionRequest {
readonly sessionId: SessionId
}
/** Complete pinned Session set after a mutation, most recently pinned first. */
export interface WorkspacePinValue {
readonly pinnedSessionIds: readonly SessionId[]
}
/** Complete reconnect baseline for Workspace browser state. */
export interface WorkspaceBaseline {
readonly items: readonly WorkspaceView[]
readonly archivedSessionIds: readonly SessionId[]
/** Registry-global pin set, most recently pinned first. */
readonly pinnedSessionIds: readonly SessionId[]
}
/** One ordered Workspace change after a generation's baseline. */
@@ -126,6 +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 pinnedSessionIds: readonly SessionId[] }
/** Workspace state stream; every generation starts with exactly one baseline. */
export type WorkspaceFollowFrame =
@@ -13,8 +13,11 @@ import type {
WorkspaceInsertBeforeRequest,
WorkspaceInsertSessionBeforeRequest,
WorkspaceOrderValue,
WorkspacePinSessionRequest,
WorkspacePinValue,
WorkspaceRenameRequest,
WorkspaceUnarchiveSessionRequest,
WorkspaceUnpinSessionRequest,
WorkspaceValue,
WorkspaceId,
WorkspaceView,
@@ -89,6 +92,14 @@ class FakeWorkspaceRemote implements WorkspaceRemote {
request: WorkspaceUnarchiveSessionRequest,
) => Promise<RemoteResult<WorkspaceArchiveValue>> = request =>
Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
onPinSession: (
request: WorkspacePinSessionRequest,
) => Promise<RemoteResult<WorkspacePinValue>> = request =>
Promise.resolve(remoteOk({ pinnedSessionIds: [request.sessionId] }))
onUnpinSession: (
_request: WorkspaceUnpinSessionRequest,
) => Promise<RemoteResult<WorkspacePinValue>> = () =>
Promise.resolve(remoteOk({ pinnedSessionIds: [] }))
create(request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
this.record('create', request)
@@ -125,6 +136,16 @@ class FakeWorkspaceRemote implements WorkspaceRemote {
return this.onUnarchiveSession(request)
}
pinSession(request: WorkspacePinSessionRequest): Promise<RemoteResult<WorkspacePinValue>> {
this.record('pinSession', request)
return this.onPinSession(request)
}
unpinSession(request: WorkspaceUnpinSessionRequest): Promise<RemoteResult<WorkspacePinValue>> {
this.record('unpinSession', request)
return this.onUnpinSession(request)
}
async *follow(_signal?: AbortSignal): AsyncGenerator<WorkspaceFollowFrame> {}
private record(method: string, request: unknown): void {
@@ -140,8 +161,9 @@ function baseline(
model: ClientWorkspaceModel,
items: readonly WorkspaceView[] = [],
archivedSessionIds: readonly SessionId[] = [],
pinnedSessionIds: readonly SessionId[] = [],
): void {
model.replaceBaseline({ items, archivedSessionIds })
model.replaceBaseline({ items, archivedSessionIds, pinnedSessionIds })
}
describe('ClientWorkspaceModel', () => {
@@ -393,6 +415,83 @@ describe('ClientWorkspaceModel', () => {
expect(model.getSnapshot().archivedSessionIds).toEqual(['second'])
})
it('applies pin mutation echoes and leaves failed results unchanged', async () => {
const remote = new FakeWorkspaceRemote()
const model = modelFor(remote)
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(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
remote.onPinSession = request => Promise.resolve(remoteOk({
pinnedSessionIds: [request.sessionId, sid('kept')],
}))
await expect(model.pinSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
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(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(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
expect(remote.calls).toContainEqual({ method: 'unpinSession', request: { sessionId: 'fresh' } })
const before = model.getSnapshot()
model.replacePinned([sid('kept')])
expect(model.getSnapshot()).toBe(before)
model.replacePinned([sid('other')])
expect(model.getSnapshot()).not.toBe(before)
expect(model.getSnapshot().pinnedSessionIds).toEqual([sid('other')])
})
it('keeps the latest pin reply when overlapping requests settle out of order', async () => {
const remote = new FakeWorkspaceRemote()
const model = modelFor(remote)
const firstGate = deferred<RemoteResult<WorkspacePinValue>>()
const secondGate = deferred<RemoteResult<WorkspacePinValue>>()
let request = 0
remote.onPinSession = () => request++ === 0 ? firstGate.promise : secondGate.promise
const first = model.pinSession(sid('first'))
const second = model.pinSession(sid('second'))
secondGate.resolve(remoteOk({ pinnedSessionIds: [sid('second'), sid('first')] }))
await expect(second).resolves.toMatchObject({ ok: true })
expect(model.getSnapshot().pinnedSessionIds).toEqual(['second', 'first'])
firstGate.resolve(remoteOk({ pinnedSessionIds: [sid('first')] }))
await expect(first).resolves.toMatchObject({ ok: true })
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, [], [], [sid('first')])
const gate = deferred<RemoteResult<WorkspacePinValue>>()
remote.onUnpinSession = () => gate.promise
const pending = model.unpinSession(sid('first'))
model.replacePinned([sid('first'), sid('second')])
gate.resolve(remoteOk({ pinnedSessionIds: [] }))
await expect(pending).resolves.toMatchObject({ ok: true })
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, [], [], [sid('pinned'), sid('kept')])
await expect(model.archiveSession(sid('pinned'))).resolves.toMatchObject({ ok: true })
expect(model.getSnapshot().archivedSessionIds).toEqual(['pinned'])
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
})
it('keeps the newest row and places Workspaces missing from partial orders last', async () => {
const model = modelFor()
baseline(model, [
@@ -19,8 +19,11 @@ import type {
WorkspaceInsertBeforeRequest,
WorkspaceInsertSessionBeforeRequest,
WorkspaceOrderValue,
WorkspacePinSessionRequest,
WorkspacePinValue,
WorkspaceRenameRequest,
WorkspaceUnarchiveSessionRequest,
WorkspaceUnpinSessionRequest,
WorkspaceValue,
WorkspaceView,
} from '../../src/types.ts'
@@ -59,12 +62,15 @@ export function workspace(id: string, overrides: Partial<WorkspaceView> = {}): W
}
/**
* A baseline frame holding the named Workspaces and no archived Sessions.
* A baseline frame holding the named Workspaces and no archived or pinned Sessions.
* @param ids - Workspace ids in registry order.
* @returns the frame.
*/
export function baseline(...ids: readonly string[]): WorkspaceBaselineFrame {
return { type: 'baseline', value: { items: ids.map(id => workspace(id)), archivedSessionIds: [] } }
return {
type: 'baseline',
value: { items: ids.map(id => workspace(id)), archivedSessionIds: [], pinnedSessionIds: [] },
}
}
/**
@@ -98,5 +104,7 @@ export const workspaceWorld: RemoteTable = {
}),
'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [request.sessionId] }),
'workspace/unarchiveSession': (_request: WorkspaceUnarchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [] }),
'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessionIds: [request.sessionId] }),
'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessionIds: [] }),
},
}
@@ -58,6 +58,7 @@ function accepts(overrides: Partial<WorkspaceFollowSink> = {}): WorkspaceFollowS
removeView: ignore,
replaceOrder: ignore,
replaceArchived: ignore,
replacePinned: ignore,
...overrides,
}
}
@@ -153,6 +154,7 @@ describe('Workspace state stream', () => {
{ type: 'remove', workspaceId: view.workspaceId },
{ type: 'order', workspaceIds: [view.workspaceId] },
{ type: 'archived', archivedSessionIds: [sid('session-one')] },
{ type: 'pinned', pinnedSessionIds: [sid('session-two')] },
]
mock.stream(FOLLOW, openStream([opening, ...increments]))
const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
@@ -160,20 +162,22 @@ describe('Workspace state stream', () => {
const removeView = vi.fn<WorkspaceFollowSink['removeView']>()
const replaceOrder = vi.fn<WorkspaceFollowSink['replaceOrder']>()
const replaceArchived = vi.fn<WorkspaceFollowSink['replaceArchived']>()
const replacePinned = vi.fn<WorkspaceFollowSink['replacePinned']>()
const stream = createWorkspaceStateStream(remote, {
accept: accepts({ replaceBaseline, upsertView, removeView, replaceOrder, replaceArchived }),
accept: accepts({ replaceBaseline, upsertView, removeView, replaceOrder, replaceArchived, replacePinned }),
failed: vi.fn(),
})
stream.start()
stream.start()
await vi.waitFor(() => { expect(replaceArchived).toHaveBeenCalledOnce() })
await vi.waitFor(() => { expect(replacePinned).toHaveBeenCalledOnce() })
expect(replaceBaseline).toHaveBeenCalledWith(opening.value)
expect(upsertView).toHaveBeenCalledWith(view)
expect(removeView).toHaveBeenCalledWith(view.workspaceId)
expect(replaceOrder).toHaveBeenCalledWith([view.workspaceId])
expect(replaceArchived).toHaveBeenCalledWith(['session-one'])
expect(replacePinned).toHaveBeenCalledWith(['session-two'])
await stream.dispose()
expect(streamStates(mock)).toEqual(['cancelled'])
})
@@ -309,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: [] })
model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [], pinnedSessionIds: [] })
const controller = new WorkspaceController(client.ctx, model)
expect(controller.list).toBe(model)
@@ -322,6 +326,8 @@ describe('WorkspaceController', () => {
})
await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined()
await expect(controller.unarchiveSession(sid('session'))).resolves.toBeUndefined()
await expect(controller.pinSession(sid('session'))).resolves.toBeUndefined()
await expect(controller.unpinSession(sid('session'))).resolves.toBeUndefined()
await expect(controller.delete(wid('one'))).resolves.toBeUndefined()
// Each command crosses the wire as one positional request object.
expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }])
@@ -330,6 +336,8 @@ describe('WorkspaceController', () => {
expect(mock.log.requests('workspace/insertSessionBefore')).toEqual([{ workspaceId: 'one', sessionId: 'session' }])
expect(mock.log.requests('workspace/archiveSession')).toEqual([{ sessionId: 'session' }])
expect(mock.log.requests('workspace/unarchiveSession')).toEqual([{ sessionId: 'session' }])
expect(mock.log.requests('workspace/pinSession')).toEqual([{ sessionId: 'session' }])
expect(mock.log.requests('workspace/unpinSession')).toEqual([{ sessionId: 'session' }])
expect(mock.log.requests('workspace/delete')).toEqual([{ workspaceId: 'one' }])
})
@@ -356,6 +364,12 @@ describe('WorkspaceController', () => {
mock.remote.workspace.unarchiveSession.mockResolvedValueOnce(err(missingSession))
await expect(controller.unarchiveSession(sid('session')))
.rejects.toThrow('workspace session unarchive failed: session/not-found: missing session')
mock.remote.workspace.pinSession.mockResolvedValueOnce(err(missingSession))
await expect(controller.pinSession(sid('session')))
.rejects.toThrow('workspace session pin failed: session/not-found: missing session')
mock.remote.workspace.unpinSession.mockResolvedValueOnce(err(missingSession))
await expect(controller.unpinSession(sid('session')))
.rejects.toThrow('workspace session unpin failed: session/not-found: missing session')
mock.remote.workspace.insertSessionBefore.mockResolvedValueOnce(err(new RemoteError(
'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') },
)))
@@ -156,6 +156,16 @@ describe('WorkspaceController commands', () => {
vi.spyOn(ctx.workspaceRegistry, 'unarchiveSession').mockRejectedValueOnce(unarchiveFailure)
await expect(controller.unarchiveSession({ sessionId: SessionId('session') }))
.rejects.toBe(unarchiveFailure)
const pinFailure = new Error('pin storage failed')
vi.spyOn(ctx.workspaceRegistry, 'pinSession').mockRejectedValueOnce(pinFailure)
await expect(controller.pinSession({ sessionId: SessionId('session') }))
.rejects.toBe(pinFailure)
const unpinFailure = new Error('unpin storage failed')
vi.spyOn(ctx.workspaceRegistry, 'unpinSession').mockRejectedValueOnce(unpinFailure)
await expect(controller.unpinSession({ sessionId: SessionId('session') }))
.rejects.toBe(unpinFailure)
})
it('resolves queued Workspace identities when their operation starts', async () => {
@@ -232,6 +242,33 @@ describe('WorkspaceController commands', () => {
await expect(controller.unarchiveSession({ sessionId: session.id }))
.resolves.toEqual({ archivedSessionIds: [] })
})
it('pins only known unarchived Sessions and unpins idempotently', async () => {
const { controller, ctx, root } = await harness()
const created = await controller.create({ path: stageDir(root, 'pins') })
const session = ctx.sessions.create(SessionId('pin-me'), {
meta: { cwd: created.workspace.path },
})
await expect(controller.pinSession({ sessionId: session.id }))
.resolves.toEqual({ pinnedSessionIds: [session.id] })
await expect(controller.pinSession({ sessionId: SessionId('unknown') }))
.rejects.toMatchObject({ code: 'session/not-found' })
// Pinning an archived Session is a caller error, not a missing session.
const archived = ctx.sessions.create(SessionId('stored'), {
meta: { cwd: created.workspace.path },
})
await controller.archiveSession({ sessionId: archived.id })
await expect(controller.pinSession({ sessionId: archived.id }))
.rejects.toMatchObject({ code: 'gateway/bad-request' })
await expect(controller.unpinSession({ sessionId: session.id }))
.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({ pinnedSessionIds: [] })
})
})
describe('WorkspaceController follow', () => {
@@ -253,18 +290,38 @@ describe('WorkspaceController follow', () => {
initialized: true,
workspaceIds: ['missing'],
archivedSessionIds: [],
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: [] },
value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] },
})
const first = await controller.create({ path: stageDir(root, 'first') })
@@ -307,6 +364,15 @@ describe('WorkspaceController follow', () => {
await expect(nextFrame(iterator)).resolves.toEqual({
type: 'archived', archivedSessionIds: [],
})
await controller.pinSession({ sessionId: session.id })
await expect(nextFrame(iterator)).resolves.toEqual({
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', pinnedSessionIds: [],
})
await controller.delete({ workspaceId: second.workspace.workspaceId })
await expect(nextFrame(iterator)).resolves.toEqual({
type: 'order', workspaceIds: [first.workspace.workspaceId],
-3
View File
@@ -263,9 +263,6 @@
- id: ui-settings-plugin-inventory
name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory'
- id: ui-settings-unarchive-sessions
name: '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions'
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
-1
View File
@@ -83,7 +83,6 @@
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^",
@@ -25,7 +25,7 @@ function emptySessions() {
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceSnapshot>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})
return bindSnapshotSelector(store)
}
@@ -212,7 +212,7 @@ function emptySessions() {
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceSnapshot>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})
return bindSnapshotSelector(store)
}
@@ -22,7 +22,7 @@ function emptySessions() {
function emptyWorkspaces() {
return bindSnapshotSelector(createSnapshotStore<WorkspaceSnapshot>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
}))
}
@@ -28,7 +28,7 @@ function emptySessions() {
function emptyWorkspaces() {
return bindSnapshotSelector(createSnapshotStore<WorkspaceSnapshot>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
}))
}
@@ -178,7 +178,7 @@ function bench(over?: BenchOptions) {
subagentsByParent: {}, jobsBySession: {},
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
(selector ?? (v => v))(key === 'plan'
@@ -62,7 +62,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useSessionRetainInfo: () => undefined,
useResource,
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: (() => undefined),
@@ -156,7 +156,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
useSessionRetainInfo: () => undefined,
useResource,
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: (() => undefined),
@@ -111,7 +111,7 @@ function workspace(id = 'w1'): WorkspaceView {
}
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
items, archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})
function sessionSnapshotOf(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
@@ -81,7 +81,7 @@ function mountFrame(windowWidth = frameWidth) {
jobsBySession: {},
})
const workspaceState: WorkspaceSnapshot = {
items: [], archivedSessionIds: [], 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)
@@ -90,18 +90,6 @@
font-weight: 500;
}
/* The design's ringed green check, not the warning tint the Toast icon seat defaults to. */
.toastIcon {
display: grid;
place-items: center;
width: 18px;
height: 18px;
border: 1.5px solid var(--dsw-alias-state-success-primary);
border-radius: 50%;
corner-shape: round;
color: var(--dsw-alias-state-success-primary);
}
@media (prefers-reduced-motion: reduce) {
.chip,
.detail {
@@ -9,7 +9,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
Button, IconCheckOutlineRegular, IconWarningOutlineRegular, Modal, Toast,
Button, IconWarningOutlineRegular, Modal, Toast,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { FeedbackCategory } from '@deepseek-ai/dsh-command-feedback/types'
import type { FeedbackDialogProps } from './slots.ts'
@@ -68,7 +68,7 @@ export function FeedbackDialog({
<Toast
key={toast}
text={t('toast.recorded')}
icon={<span className={css.toastIcon}><IconCheckOutlineRegular size={12} /></span>}
tone="success"
anchor={card}
onDone={onToastDone}
/>
@@ -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-primitives/README.md
README.md: 09c56614799b30c2c6829620bbf14d7cc55cb0b0
README.zh.md: ced758f9b43605365b43a62363bac0b0ca8a6df2
README.md: f1267219e6bc50bccdd2dd65771ac8577ddc2e26
README.zh.md: 15c327c3bd756d8d0684c15c858d4443008d03cc
+1 -1
View File
@@ -71,7 +71,7 @@ Writing your own component in your own package is fine when the need is genuinel
### Controls and icons
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
### Rendering agent output
+1 -1
View File
@@ -71,7 +71,7 @@ kind: "package-library"
### 控件与图标
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
### 渲染 agent 输出
@@ -240,11 +240,12 @@
color: var(--dsw-alias-label-tertiary);
}
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. l2, not
* l1, for the same reason as .footer: l1 is near-invisible on this surface. */
.separator {
height: 0.5px;
margin: 3px 2px;
background: var(--dsw-alias-border-l1);
background: var(--dsw-alias-border-l2);
}
/* Nested card to the right of the parent row.
+7 -2
View File
@@ -87,9 +87,13 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* (`'check'`, default — figma .Menu_cell) or the hover fill held on the row
* with no check (`'fill'`, for icon-labelled rows where a trailing glyph
* crowds the cell).
* @param props.className - extra class on the anchor wrapper span.
* @param props.listClassName - extra class on the dropdown card itself; the
* only style hook that reaches a portaled list, which renders under
* document.body outside the owner's DOM subtree.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className }: {
export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className, listClassName }: {
open: boolean
autoFocus?: boolean
anchor: ReactNode
@@ -108,6 +112,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
selection?: 'check' | 'fill'
getAnchorRect?: () => DOMRect | null
className?: string | undefined
listClassName?: string | undefined
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const listRef = useRef<HTMLDivElement>(null)
@@ -404,7 +409,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
const list = open && (
<div
ref={listRef}
className={clsx(css.list, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
className={clsx(css.list, listClassName, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role="menu"
// React portals bubble synthetic events through the REACT tree: without
@@ -23,8 +23,11 @@
max-width: min(640px, calc(100vw - 48px));
padding: 12px 16px;
border-radius: 14px;
background: var(--dsw-alias-button-contrast-fill);
color: var(--dsw-alias-label-primary-inverted);
/* The design platform's toast surface is dark in BOTH themes (bluish-800
light / bluish-750 dark), so the label and the action text below stay
fixed instead of flipping with the theme. */
background: var(--dsw-alias-toast-bg);
color: var(--dsw-static-neutral-bluish-00);
font-size: 14px;
line-height: 22px;
box-shadow: var(--dsw-shadow-lv3);
@@ -41,10 +44,35 @@
color: var(--dsw-alias-state-warn-label);
}
.icon.success {
color: var(--dsw-alias-state-success-primary);
}
.text {
min-width: 0;
}
.action {
display: inline;
padding: 0;
border: 0;
background: none;
/* deepseek-400, brightened at the designer's request, reads as the action
against the always-dark toast surface without an underline. */
color: var(--dsw-static-deepseek-400);
font: inherit;
cursor: pointer;
/* Breathing room where the action text meets the surrounding copy. */
margin-inline: 3px;
/* The banner surface stays click-through (`pointer-events: none` above);
only its action text takes the pointer. */
pointer-events: auto;
}
.action:hover {
opacity: 0.8;
}
@keyframes dsh-toast-in {
from {
opacity: 0;
@@ -60,6 +88,7 @@
@keyframes dsh-toast-fade {
to {
opacity: 0;
visibility: hidden;
}
}
+35 -7
View File
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useState } from 'react'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { IconCheckCircleOutlineRegular } from './icons/index.tsx'
import css from './Toast.module.css'
/** Full-opacity hold before the fade starts, when the owner names none. */
@@ -15,6 +16,8 @@ const FADE_MS = 1000
* per-show sequence). Rendered through a body portal so an owner inside a
* transformed or filtered ancestor cannot trap the fixed banner in that
* ancestor's box.
* With unchanged holdMs, parent rerenders do not extend the lifetime.
* Completion calls the latest onDone handler; fully faded actions receive no input.
*
* The hold is the owner's to set, because how long a banner has to stay
* depends on how much there is to read: a one-line limit lands in the default
@@ -23,7 +26,16 @@ const FADE_MS = 1000
* reads it as a custom property — so the two can no longer disagree and leave
* the banner unmounting mid-fade.
* @param props.text - resolved banner copy; the owner passes localized text.
* @param props.icon - optional leading glyph (e.g. a warning icon).
* @param props.icon - optional leading glyph (e.g. a warning icon); ignored
* under `tone="success"`, which brings its own glyph.
* @param props.tone - 'success' renders the design's circled green check as
* the leading glyph; omitted, the icon seat keeps its warning tint.
* @param props.actions - optional inline actions continuing the sentence:
* each renders its plain-text `prefix` (a connective like 或) followed by its
* localized `label` as blue clickable text, flowing after `text` as one
* sentence. Each press is the owner's to handle (e.g. undo the reported
* change, then unmount the toast). The banner surface stays click-through —
* only the action text takes the pointer.
* @param props.holdMs - full-opacity hold before the fade; defaults to 3000.
* @param props.anchor - optional element whose horizontal center the banner
* follows (e.g. the composer card, so the banner centers over the chat column
@@ -31,17 +43,21 @@ const FADE_MS = 1000
* @param props.onDone - called once the fade completes; unmount the toast here.
* @returns the floating banner.
*/
export function Toast({ text, icon, anchor, holdMs = HOLD_MS, onDone }: {
export function Toast({ text, icon, tone, anchor, holdMs = HOLD_MS, actions, onDone }: {
text: string
icon?: ReactNode
tone?: 'success'
anchor?: HTMLElement | null
holdMs?: number
actions?: readonly { label: string; prefix?: string; onClick: () => void }[]
onDone: () => void
}) {
const latestOnDone = useRef(onDone)
useLayoutEffect(() => { latestOnDone.current = onDone }, [onDone])
useEffect(() => {
const timer = setTimeout(onDone, holdMs + FADE_MS)
const timer = setTimeout(() => { latestOnDone.current() }, holdMs + FADE_MS)
return () => { clearTimeout(timer) }
}, [holdMs, onDone])
}, [holdMs])
// Anchor-centered placement re-measures on window resizes; the banner lives
// four seconds, so sub-window layout drift within that span stays out of
// scope.
@@ -65,8 +81,20 @@ export function Toast({ text, icon, anchor, holdMs = HOLD_MS, onDone }: {
'--dsh-toast-hold': `${String(holdMs)}ms`,
} as CSSProperties}
>
{icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
<span className={css.text}>{text}</span>
{tone === 'success'
? <span className={`${css.icon} ${css.success}`} aria-hidden><IconCheckCircleOutlineRegular /></span>
: icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
<span className={css.text}>
{text}
{actions?.map(action => (
<Fragment key={action.label}>
{action.prefix}
<button type="button" className={css.action} onClick={action.onClick}>
{action.label}
</button>
</Fragment>
))}
</span>
</div>,
document.body,
)
@@ -31,6 +31,16 @@
transform: translate(-50%, -100%);
}
/* End alignment: `left` carries the anchor's right edge, so the bubble sits
fully left of it instead of centering. */
.bubble[data-side='bottom'][data-align='end'] {
transform: translateX(-100%);
}
.bubble[data-side='top'][data-align='end'] {
transform: translate(-100%, -100%);
}
@keyframes tooltip-in {
from { opacity: 0; }
}
+31 -4
View File
@@ -20,25 +20,43 @@ interface AnchorProps {
ref?: Ref<HTMLElement> | undefined
onMouseEnter?: MouseEventHandler | undefined
onMouseLeave?: MouseEventHandler | undefined
onClick?: MouseEventHandler | undefined
onFocus?: FocusEventHandler | undefined
onBlur?: FocusEventHandler | undefined
}
type TooltipLabel = string | (() => string)
// Focus alone cannot reveal how it arrived: a closing menu hands focus back to
// its trigger, and after a mouse selection that programmatic return must not
// raise the trigger's bubble, while keyboard focus must. Capture-phase window
// listeners record the last input modality for every Tooltip. The guard keeps
// the module loadable where no window exists (node-side imports of the
// package's pure helpers).
let pointerModality = false
if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', () => { pointerModality = true }, true)
window.addEventListener('keydown', () => { pointerModality = false }, true)
}
/**
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text, or a resolver evaluated only while the bubble is visible.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.align - horizontal anchor-edge alignment for 'bottom'/'top' bubbles: 'end' pins
* the bubble's right edge to the anchor's (for anchors beside other hover surfaces the centered
* bubble would overlap); default 'center'. Ignored for side 'right'.
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
* toggling never remounts it (which would cut its CSS transitions).
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
* half-viewport cap would render a slab wider than the surface the anchor sits on.
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused; clicking the
* anchor dismisses the bubble until the next trigger, and focus arriving after a pointer
* interaction (a closing menu refocusing its trigger) never raises it.
*/
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
@@ -139,7 +157,11 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
// Every show starts from the requested side; the fit pass flips it only
// where this anchor's position demands it.
setPlacement(side)
setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom })
setPos({
x: side === 'right' ? r.right + 10 : align === 'end' ? r.right : r.left + r.width / 2,
top: r.top,
bottom: r.bottom,
})
announce(true)
}
const showAfterHoverDelay = () => {
@@ -168,7 +190,11 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
ref: mergedRef,
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() },
// Activating the anchor dismisses the bubble: the action often changes
// what the anchor now does (pin → unpin), and the click leaves the
// anchor focused, which would otherwise pin the relabelled bubble up.
onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() },
onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality) return; triggers.current.focus = true; cancelShow(); show() },
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}
{visible && !suppressed && (
@@ -176,6 +202,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
ref={bubble}
className={css.bubble}
data-side={placement}
data-align={align}
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
role="tooltip"
>
@@ -96,9 +96,9 @@ export const IconPanelLeftOutlineMedium = (props: IconProps) => (
const IconEllipsisOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M3 9.25C3.69036 9.25 4.25 8.69036 4.25 8C4.25 7.30964 3.69036 6.75 3 6.75C2.30964 6.75 1.75 7.30964 1.75 8C1.75 8.69036 2.30964 9.25 3 9.25Z" fill="currentColor" />
<path d="M8 9.25C8.69036 9.25 9.25 8.69036 9.25 8C9.25 7.30964 8.69036 6.75 8 6.75C7.30964 6.75 6.75 7.30964 6.75 8C6.75 8.69036 7.30964 9.25 8 9.25Z" fill="currentColor" />
<path d="M13 9.25C13.6904 9.25 14.25 8.69036 14.25 8C14.25 7.30964 13.6904 6.75 13 6.75C12.3096 6.75 11.75 7.30964 11.75 8C11.75 8.69036 12.3096 9.25 13 9.25Z" fill="currentColor" />
<path d="M3 9C3.55228 9 4 8.55228 4 8C4 7.44772 3.55228 7 3 7C2.44772 7 2 7.44772 2 8C2 8.55228 2.44772 9 3 9Z" fill="currentColor" />
<path d="M8 9C8.55228 9 9 8.55228 9 8C9 7.44772 8.55228 7 8 7C7.44772 7 7 7.44772 7 8C7 8.55228 7.44772 9 8 9Z" fill="currentColor" />
<path d="M13 9C13.5523 9 14 8.55228 14 8C14 7.44772 13.5523 7 13 7C12.4477 7 12 7.44772 12 8C12 8.55228 12.4477 9 13 9Z" fill="currentColor" />
</svg>
)
@@ -1403,3 +1403,175 @@ export const IconShieldOutlineRegular = (props: IconProps) => (
export const IconShieldOutlineMedium = (props: IconProps) => (
<IconShieldOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconCheckCircleOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path
d="M12.5303 6.53027L8.80273 10.2578C8.54967 10.5109 8.31796 10.7439 8.10645 10.9141C7.88375 11.0932 7.616 11.2602 7.27344 11.3145C7.09229 11.3431 6.90771 11.3431 6.72656 11.3145C6.384 11.2602 6.11625 11.0932 5.89355 10.9141C5.68204 10.7439 5.45033 10.5109 5.19727 10.2578L3.46973 8.53027L4.53027 7.46973L6.25781 9.19727C6.53457 9.47402 6.70036 9.63859 6.83398 9.74609C6.95637 9.84453 6.98241 9.83644 6.96094 9.83301C6.98679 9.83709 7.01321 9.83709 7.03906 9.83301C7.01759 9.83644 7.04363 9.84453 7.16602 9.74609C7.29964 9.63859 7.46543 9.47402 7.74219 9.19727L11.4697 5.46973L12.5303 6.53027Z"
fill="currentColor"
/>
<path
d="M14.5996 8C14.5996 4.35492 11.6451 1.40039 8 1.40039C4.35492 1.40039 1.40039 4.35492 1.40039 8C1.40039 11.6451 4.35492 14.5996 8 14.5996C11.6451 14.5996 14.5996 11.6451 14.5996 8ZM15.9004 8C15.9004 12.363 12.363 15.9004 8 15.9004C3.63695 15.9004 0.0996094 12.363 0.0996094 8C0.0996094 3.63695 3.63695 0.0996094 8 0.0996094C12.363 0.0996094 15.9004 3.63695 15.9004 8Z"
fill="currentColor"
/>
</svg>
)
/** Regular one-pixel IconCheckCircleOutline artwork. */
export const IconCheckCircleOutlineRegular = (props: IconProps) => (
<IconCheckCircleOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconCheckCircleOutline artwork with a 1.3px stroke. */
export const IconCheckCircleOutlineMedium = (props: IconProps) => (
<IconCheckCircleOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconUnarchiveOutlineArtwork = ({ size = 20, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.8659 2.05975C17.2603 2.05995 18.3913 3.19096 18.3914 4.58527V5.4874C18.3914 6.02747 18.2192 6.52672 17.9303 6.93735C17.9336 6.96524 17.9388 6.99318 17.9388 7.02195V12.8884C17.9388 13.6345 17.9395 14.2379 17.8996 14.7254C17.8642 15.1593 17.7936 15.5499 17.6373 15.9141L17.5654 16.0685C17.278 16.6328 16.8405 17.1046 16.3038 17.434L16.0679 17.5661C15.66 17.7739 15.2196 17.8598 14.7237 17.9003C14.2362 17.9401 13.6327 17.9405 12.8867 17.9405H7.11122C6.36511 17.9405 5.76171 17.9401 5.27418 17.9003C4.84051 17.8649 4.44949 17.7952 4.08545 17.6391L3.93104 17.5661C3.36673 17.2785 2.89392 16.8414 2.56465 16.3044L2.43245 16.0685C2.22473 15.6608 2.13878 15.2211 2.09825 14.7254C2.05841 14.2379 2.05912 13.6345 2.05912 12.8884V7.02195C2.05912 6.99284 2.06422 6.96449 2.06758 6.93629C1.77931 6.52592 1.60858 6.02687 1.60858 5.4874V4.58527C1.60876 3.19084 2.73962 2.05975 4.1341 2.05975H15.8659ZM16.4984 7.92936C16.296 7.98169 16.0847 8.01288 15.8659 8.01291H4.1341C3.91478 8.01291 3.70246 7.98194 3.49955 7.92936V12.8884C3.49955 13.6582 3.50053 14.1927 3.53445 14.608C3.56769 15.0146 3.62923 15.244 3.71635 15.415L3.7925 15.5514C3.98339 15.8627 4.25749 16.1165 4.58464 16.2833L4.72529 16.3435C4.88095 16.3993 5.08638 16.4402 5.39158 16.4651C5.80685 16.4991 6.34138 16.5001 7.11122 16.5001H12.8867C13.6564 16.5001 14.1911 16.499 14.6063 16.4651C15.0128 16.432 15.2423 16.3703 15.4133 16.2833L15.5508 16.2061C15.8618 16.0152 16.116 15.7419 16.2827 15.415L16.3429 15.2732C16.3985 15.1177 16.4396 14.9128 16.4645 14.608C16.4985 14.1927 16.4984 13.6583 16.4984 12.8884V7.92936ZM4.1341 3.50019C3.53511 3.50019 3.0492 3.98631 3.04902 4.58527V5.4874C3.04902 6.08649 3.535 6.57248 4.1341 6.57248H15.8659C16.4648 6.57228 16.951 6.08638 16.951 5.4874V4.58527C16.9509 3.98644 16.4647 3.50038 15.8659 3.50019H4.1341Z"
fill="currentColor"
/>
<path d="M10 14.1V10.1M7.85 12.05L10 9.9L12.15 12.05" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** Regular one-pixel IconUnarchiveOutline artwork. */
export const IconUnarchiveOutlineRegular = (props: IconProps) => (
<IconUnarchiveOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconUnarchiveOutline artwork with a 1.3px stroke. */
export const IconUnarchiveOutlineMedium = (props: IconProps) => (
<IconUnarchiveOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconPinOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M9.96976 1.70572L13.1554 3.93629L10.9019 8.12317L11.5158 11.605L10.7192 12.7427L2.52767 7.00693L3.3243 5.86922L6.80612 5.25528L9.96976 1.70572Z" stroke="currentColor" strokeLinejoin="round" />
<path d="M6.05285 9.47511C6.27284 9.16094 6.70586 9.08458 7.02003 9.30457C7.3342 9.52455 7.41055 9.95757 7.19057 10.2717L3.98587 14.4708L3.21223 13.9291L6.05285 9.47511Z" fill="currentColor" />
</svg>
)
/** Regular one-pixel IconPinOutline artwork. */
export const IconPinOutlineRegular = (props: IconProps) => (
<IconPinOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconPinOutline artwork with a 1.3px stroke. */
export const IconPinOutlineMedium = (props: IconProps) => (
<IconPinOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconPinFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M9.96976 1.70572L13.1554 3.93629L10.9019 8.12317L11.5158 11.605L10.7192 12.7427L2.52767 7.00693L3.3243 5.86922L6.80612 5.25528L9.96976 1.70572Z" fill="currentColor" stroke="currentColor" strokeLinejoin="round" />
<path d="M6.05285 9.47511C6.27284 9.16094 6.70586 9.08458 7.02003 9.30457C7.3342 9.52455 7.41055 9.95757 7.19057 10.2717L3.98587 14.4708L3.21223 13.9291L6.05285 9.47511Z" fill="currentColor" />
</svg>
)
/** Regular one-pixel IconPinFill artwork. */
export const IconPinFillRegular = (props: IconProps) => (
<IconPinFillArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconPinFill artwork with a 1.3px stroke. */
export const IconPinFillMedium = (props: IconProps) => (
<IconPinFillArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconFlatListOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeLinecap="round" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M6 3.5h7.5M6 8h7.5M6 12.5h7.5" />
<path d="M2.6 3.5h.01M2.6 8h.01M2.6 12.5h.01" />
</svg>
)
/** Regular one-pixel IconFlatListOutline artwork. */
export const IconFlatListOutlineRegular = (props: IconProps) => (
<IconFlatListOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconFlatListOutline artwork with a 1.3px stroke. */
export const IconFlatListOutlineMedium = (props: IconProps) => (
<IconFlatListOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconWorkspaceTreeOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M14 12.05c0 .8-.65 1.45-1.46 1.45H3.46C2.65 13.5 2 12.85 2 12.05v-8.1c0-.8.65-1.45 1.46-1.45h2.4c.49 0 .94.24 1.21.65l.5.73c.27.4.73.65 1.21.65h3.76c.8 0 1.46.65 1.46 1.45v6.02Z" />
<path d="M8.7 8.1v3M11.2 8.1v3" />
</svg>
)
/** Regular one-pixel IconWorkspaceTreeOutline artwork. */
export const IconWorkspaceTreeOutlineRegular = (props: IconProps) => (
<IconWorkspaceTreeOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconWorkspaceTreeOutline artwork with a 1.3px stroke. */
export const IconWorkspaceTreeOutlineMedium = (props: IconProps) => (
<IconWorkspaceTreeOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconChevronsUpDownOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="m5.1 6 2.9-2.9L10.9 6" />
<path d="m5.1 10 2.9 2.9 2.9-2.9" />
</svg>
)
/** Regular one-pixel IconChevronsUpDownOutline artwork. */
export const IconChevronsUpDownOutlineRegular = (props: IconProps) => (
<IconChevronsUpDownOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconChevronsUpDownOutline artwork with a 1.3px stroke. */
export const IconChevronsUpDownOutlineMedium = (props: IconProps) => (
<IconChevronsUpDownOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconArchiveCheckOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" strokeWidth={strokeWidth}>
<rect x="1.9" y="2.1" width="12.2" height="3.4" rx="1.1" />
<path d="M2.95 5.7v4.8a2.9 2.9 0 0 0 2.9 2.9h4.3a2.9 2.9 0 0 0 2.9-2.9V5.7" />
<path d="m6 9.35 1.4 1.4 2.6-2.6" />
</svg>
)
/** Regular one-pixel IconArchiveCheckOutline artwork. */
export const IconArchiveCheckOutlineRegular = (props: IconProps) => (
<IconArchiveCheckOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconArchiveCheckOutline artwork with a 1.3px stroke. */
export const IconArchiveCheckOutlineMedium = (props: IconProps) => (
<IconArchiveCheckOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconSlidersTwoOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeLinecap="round" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M2.3 5h5.85M12.05 5h1.65" />
<circle cx="9.95" cy="5" r="1.45" />
<path d="M2.3 11h1.65M7.85 11h5.85" />
<circle cx="5.75" cy="11" r="1.45" />
</svg>
)
/** Regular one-pixel IconSlidersTwoOutline artwork. */
export const IconSlidersTwoOutlineRegular = (props: IconProps) => (
<IconSlidersTwoOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconSlidersTwoOutline artwork with a 1.3px stroke. */
export const IconSlidersTwoOutlineMedium = (props: IconProps) => (
<IconSlidersTwoOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
@@ -17,8 +17,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('product icon set', () => {
it('exports regular and medium variants for all 81 public glyphs', () => {
expect(iconNames.length).toBe(162)
it('exports regular and medium variants for all 90 public glyphs', () => {
expect(iconNames.length).toBe(180)
expect(iconNames.some(name => /\d+$/.test(name))).toBe(false)
const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length))
const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length))
@@ -42,6 +42,25 @@ describe('Toast', () => {
}
})
it('keeps its original expiry across rerenders and calls the latest completion handler', () => {
vi.useFakeTimers()
try {
const first = vi.fn()
const latest = vi.fn()
const view = render(<Toast text="archived" onDone={first} />)
vi.advanceTimersByTime(1000)
view.rerender(<Toast text="archived" onDone={latest} />)
vi.advanceTimersByTime(2999)
expect(first).not.toHaveBeenCalled()
expect(latest).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(first).not.toHaveBeenCalled()
expect(latest).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('centers over its anchor and re-measures on window resize', () => {
vi.useFakeTimers()
try {
@@ -59,6 +78,36 @@ describe('Toast', () => {
}
})
it('flows prefixed actions inline as one sentence and hands each press to the owner', () => {
vi.useFakeTimers()
try {
const undo = vi.fn()
const filter = vi.fn()
const view = render(
<Toast
text="会话已归档,可"
tone="success"
actions={[
{ label: '撤销', onClick: undo },
{ prefix: '或', label: '筛选已归档会话', onClick: filter },
]}
onDone={vi.fn()}
/>,
)
// The success tone brings the circled green check itself; no icon prop.
const glyph = view.getByRole('alert').querySelector('[aria-hidden]')
expect(glyph?.className).toContain('success')
expect(glyph?.querySelector('svg')).toBeTruthy()
expect(view.getByRole('alert').textContent).toBe('会话已归档,可撤销或筛选已归档会话')
fireEvent.click(view.getByRole('button', { name: '撤销' }))
expect(undo).toHaveBeenCalledTimes(1)
fireEvent.click(view.getByRole('button', { name: '筛选已归档会话' }))
expect(filter).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('renders without an icon and cancels its timer on unmount', () => {
vi.useFakeTimers()
try {
@@ -0,0 +1,10 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { Tooltip } from '../src/Tooltip.tsx'
describe('Tooltip module in Node', () => {
it('loads without installing browser input listeners', () => {
expect(typeof window).toBe('undefined')
expect(Tooltip).toBeTypeOf('function')
})
})
@@ -95,6 +95,23 @@ describe('Tooltip', () => {
const rect = (left: number, right: number): DOMRect =>
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
it('aligns the end of a bottom tooltip with the anchor right edge', () => {
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(100, 200))
try {
render(
<Tooltip label="End aligned" side="bottom" align="end">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-align')).toBe('end')
expect(bubble.style.left).toBe('200px')
} finally {
spy.mockRestore()
}
})
it('caps the bubble width where the label would otherwise slab across the surface', () => {
render(
<Tooltip label="A description long enough to need a cap" side="bottom" maxWidth={360}>
@@ -257,23 +274,66 @@ describe('Tooltip', () => {
}
})
it('a click on the anchor dismisses the bubble even while the anchor stays focused', () => {
render(
<Tooltip label="Pin session">
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
// Pointer click: browsers focus the button first, then deliver the click.
fireEvent.focus(anchor)
fireEvent.mouseEnter(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.click(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
// The retained focus alone must not resurrect it on mouse leave.
fireEvent.mouseLeave(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
// A fresh hover shows the (possibly relabelled) bubble again.
fireEvent.mouseEnter(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
})
it('focus arriving after a pointer interaction does not raise the bubble', () => {
render(
<Tooltip label="View options">
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
// A closing menu refocuses its trigger after a mouse selection: the last
// interaction was a pointerdown on the menu row, not a key press.
fireEvent.pointerDown(document.body)
fireEvent.focus(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.blur(anchor)
// The next key press restores focus-driven bubbles (keyboard selection).
fireEvent.keyDown(document.body, { key: 'Tab' })
fireEvent.focus(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
})
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
const onMouseEnter = vi.fn()
const onMouseLeave = vi.fn()
const onClick = vi.fn()
const onFocus = vi.fn()
const onBlur = vi.fn()
render(
<Tooltip label="Chained">
<button type="button" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onFocus={onFocus} onBlur={onBlur}>anchor</button>
<button type="button" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick} onFocus={onFocus} onBlur={onBlur}>anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
fireEvent.mouseEnter(anchor)
fireEvent.mouseLeave(anchor)
fireEvent.click(anchor)
fireEvent.focus(anchor)
fireEvent.blur(anchor)
expect(onMouseEnter).toHaveBeenCalledOnce()
expect(onMouseLeave).toHaveBeenCalledOnce()
expect(onClick).toHaveBeenCalledOnce()
expect(onFocus).toHaveBeenCalledOnce()
expect(onBlur).toHaveBeenCalledOnce()
})
@@ -37,10 +37,10 @@ const CHILD_NAMES = Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>
/**
* Section ids the web-app roster registers, in nav order: this package, then
* ui-settings-models, ui-settings-plugins, ui-agent-preset, and
* ui-settings-unarchive-sessions. A plugin adding a section changes this list.
* ui-settings-models, ui-settings-plugins, and ui-agent-preset. A plugin
* adding a section changes this list.
*/
const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets', 'archived-sessions']
const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets']
/** Onboarding steps the web-app roster registers, in coordinator order; both come from ui-settings-models. */
const PRODUCT_ONBOARDING: readonly { id: string; order: number }[] = [
{ id: 'welcome-notice', order: -100 },
@@ -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 packages/client/ui-settings-unarchive-sessions/README.md
README.md: 88a5835c2bf2a2e4799ee02e94985db9162e4ef0
README.zh.md: 9e2a907d75a84a4e3262fc157023d26f93919a5d
@@ -1,112 +0,0 @@
---
description: "Archived-session Settings page for the dsh web client: the registry-global archive set as a searchable list with one Unarchive action per row."
kind: "package-reference"
---
# @deepseek-ai/dsh-client-ui-settings-unarchive-sessions
English | [中文](README.zh.md)
## Summary
The **Archived sessions** Settings page is the restore point for sessions hidden from Workspace navigation. It lists each archived session with the Workspace that owns it and its last activity, newest archive first, and offers one Unarchive action per row. A search box filters the list by session title or Workspace name. Rows come from the archive set joined with the loaded Session summaries, so an archive entry whose session record is gone has no row and no action. Every restore goes through the shared Workspace command.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
Open Settings and select **Archived sessions** to see the sessions currently hidden from every grouping surface. Mount `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` in a Web composition that already provides the settings shell, the Workspace service, and the Session list; the page registers its own navigation entry and needs no configuration.
### Reading a row
Each row shows the session's display title, the title of the Workspace that accounts for it or the ungrouped label, and its last activity as a compact relative time. The page lists the most recently archived session first, the reverse of the durable archive order. The page waits for the Session list before rendering rows; while that list loads it shows a reading status instead of an empty archive. An empty archive, an archive whose entries have no loaded Session to restore, and a query matching no row report three different messages, so neither a search nor an unaddressable entry ever looks like an empty archive.
### Restoring a session
Unarchive restores the session to its recorded position under its Workspace, or to the ungrouped sessions when it belongs to none, and the row disappears from the page. The action calls `ctx.uiWorkspace.unarchiveSession`, whose echoed archive set updates every surface that filters on it, so the session reappears in the sidebar and search as well. A rejected call is logged as a console diagnostic and leaves the row in place for another attempt.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
The page is one localized `settings.section` contribution with id `archived-sessions`; the Settings shell owns the navigation entry, the modal, and the mounted section, so none of that chrome lives here.
### Registration and data sources
`apply()` registers the locale namespace, binds it, and uses `ctx.slots.inject()` to contribute the section, so a late or restored slot declaration still reaches it. The page reads `state.archivedSessionIds` and `state.items` from `useWorkspaces` and the Session summaries from `useSessions`; it owns no store and no transport, and its only write is the injected `unarchive` callback that the registration closes over `ctx.uiWorkspace.unarchiveSession`.
### Row derivation
Rows are derived from the archive set joined with the loaded summaries: a member with no summary produces no row, which is why a session deleted outside the Workspace registry leaves no unarchive action behind. Workspace ownership is read from each Workspace's `sessionIds`; a member outside every Workspace renders with the ungrouped label. Search normalizes the query once and matches it against the row title and Workspace label.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Host loader entry: the page is browser-only, so the plugin body is empty |
| [`src/client/index.ts`](src/client/index.ts) | Browser plugin: locale namespace, section registration, injected Unarchive operation |
| [`src/client/ArchivedSessionsSection.tsx`](src/client/ArchivedSessionsSection.tsx) | The page component: row derivation, search, per-row Unarchive |
| [`src/client/locales.ts`](src/client/locales.ts) | Chinese and English dictionaries for every visible and accessible string |
| [`src/client/ArchivedSessionsSection.module.css`](src/client/ArchivedSessionsSection.module.css) | Page styles |
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
These pages cover the settings surface that hosts the page, the archive write behind it, and the state it renders.
- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.section` and the namespace scope service.
- [ui-settings-general](../ui-settings-general/README.md) — the Settings shell that renders the navigation and mounts the section.
- [ui-workspace](../ui-workspace/README.md) — the sidebar browser whose Session rows archive, and the `ctx.uiWorkspace` service this page restores through.
- [Workspace Controller](../../api/workspace-controller/README.md) — the `workspace.unarchiveSession` Remote and the Client model that owns the archive set.
- [Workspace subsystem](../../../docs/subsystems/workspace.md) — the durable archive set, its domain field, and the registry operation behind a restore.
-----
<a id="model-experience"></a>
## Model Experience
None, as the package is a browser-side UI plugin layer that registers nothing model-facing.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
These limits define which archived sessions this page can restore; they are current package constraints.
- **Archived sessions without a loaded summary are unaddressable** — the page derives its rows by joining the archive set with the Session list, so a member the list does not carry has no row and no Unarchive action even though the archive set still holds it; a set whose members are all in that state reports itself as unrestorable rather than empty.
- **The page lists sessions only; it offers no session deletion** — archives are reversible through this page, while deleting a session record remains a separate capability.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
**Runtime invariant:** No companion is published. A browser-side settings page that registers one localized `settings.section` contribution and its locale namespace; it emits no Cordis events and owns no cross-plugin mutable relation.
@@ -1,112 +0,0 @@
---
description: "dsh Web 客户端的已归档会话设置页:把注册表全局归档集合呈现为可搜索列表,每行提供一个取消归档操作。"
kind: "package-reference"
---
# @deepseek-ai/dsh-client-ui-settings-unarchive-sessions
[English](README.md) | 中文
## 概述
**已归档会话**设置页是从 Workspace 导航中隐藏的会话的恢复入口。它按归档时间由新到旧列出每个已归档会话,并显示其所属 Workspace 与最近活动时间,每行提供一个取消归档操作。搜索框按会话标题或 Workspace 名称过滤列表。行由归档集合与已加载的 Session 摘要合并而来,因此会话记录已不存在的归档条目既没有行也没有操作。每次恢复都经由共享的 Workspace 命令。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
打开设置并选择**已归档会话**,即可看到当前从所有分组视图中隐藏的会话。在已提供设置外壳、Workspace 服务与 Session 列表的 Web 组合中挂载 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`;该页面注册自己的导航条目,无需配置。
### 阅读一行
每行显示会话的显示标题、为其记账的 Workspace 标题或未分组标签,以及以紧凑相对时间表示的最近活动。页面把最近归档的会话排在前面,与持久归档顺序相反。页面会先等待 Session 列表再渲染行;列表加载期间显示读取状态,而不是空归档。空归档、归档条目均无已加载 Session 可恢复、以及查询无匹配分别给出三种提示,因此搜索和不可寻址的条目都不会被误认为归档为空。
### 恢复会话
取消归档会把会话恢复到其 Workspace 下记录的位置;会话不属于任何 Workspace 时则恢复到未分组会话中,该行随即从页面消失。该操作调用 `ctx.uiWorkspace.unarchiveSession`,其回传的完整归档集合会更新所有依据它过滤的界面,因此会话也会重新出现在侧边栏与搜索中。调用被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现细节——点击展开</summary>
页面是 id 为 `archived-sessions` 的一个本地化 `settings.section` 贡献;导航条目、模态框与挂载的分区都归设置外壳所有,不在此包内。
### 注册与数据来源
`apply()` 注册并绑定 locale namespace,再用 `ctx.slots.inject()` 贡献该分区,因此延迟声明或恢复声明的 slot 依然能触达它。页面从 `useWorkspaces` 读取 `state.archivedSessionIds` 与 `state.items`,从 `useSessions` 读取 Session 摘要;它不持有 store 也不持有 transport,唯一的写入是注册处以 `ctx.uiWorkspace.unarchiveSession` 闭包注入的 `unarchive` 回调。
### 行的派生
行由归档集合与已加载摘要合并派生:没有摘要的成员不产生行,因此在 Workspace 注册表之外被删除的会话不会留下取消归档操作。Workspace 归属从各 Workspace 的 `sessionIds` 读取;不属于任何 Workspace 的成员以未分组标签渲染。搜索只规范化一次查询,并与行标题和 Workspace 标签匹配。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 宿主 loader 入口:该页面仅供浏览器使用,因此插件体为空 |
| [`src/client/index.ts`](src/client/index.ts) | 浏览器插件:locale namespace、分区注册、注入的取消归档操作 |
| [`src/client/ArchivedSessionsSection.tsx`](src/client/ArchivedSessionsSection.tsx) | 页面组件:行的派生、搜索、每行的取消归档 |
| [`src/client/locales.ts`](src/client/locales.ts) | 全部可见与无障碍字符串的中英文字典 |
| [`src/client/ArchivedSessionsSection.module.css`](src/client/ArchivedSessionsSection.module.css) | 页面样式 |
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
以下页面覆盖承载该页面的设置界面、其背后的归档写入,以及它所渲染的状态。
- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.section` 与 namespace scope 服务的领域底座。
- [ui-settings-general](../ui-settings-general/README.zh.md)——渲染导航并挂载该分区的设置外壳。
- [ui-workspace](../ui-workspace/README.zh.md)——其 Session 行负责归档的侧边栏浏览器,以及本页面借以恢复的 `ctx.uiWorkspace` 服务。
- [Workspace Controller](../../api/workspace-controller/README.zh.md)——`workspace.unarchiveSession` Remote 与持有归档集合的 Client model。
- [Workspace 子系统](../../../docs/subsystems/workspace.zh.md)——持久归档集合、其领域字段,以及恢复背后的注册表操作。
-----
<a id="model-experience"></a>
## 模型体验
无。该包是浏览器端 UI 插件层,不注册任何面向模型的内容。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与延期工作
<a id="known-limitations-and-deferred-work"></a>
这些限制界定本页面能够恢复哪些已归档会话;它们是当前包约束。
- **已加载摘要缺失的已归档会话无法寻址**:页面通过与 Session 列表合并来派生行,因此列表未携带的成员没有行也没有取消归档操作,尽管归档集合仍持有它;当集合中的成员全部处于该状态时,页面报告无法恢复,而不是报告归档为空。
- **页面只列出会话,不提供会话删除**:归档可通过本页面恢复,而删除会话记录仍是彼此独立的能力。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
无。
</details>
**运行时不变式:** 不发布伴生入口。本包是浏览器端设置页,只注册一个本地化 `settings.section` 贡献及其 locale namespace;它不发出 Cordis 事件,也不持有跨插件可变关系。
@@ -1,71 +0,0 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions",
"description": "Archived-session settings page: the registry-global archive set with one Unarchive action per row",
"version": "0.1.6-alpha.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-settings-unarchive-sessions"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-workspace"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}
@@ -1,83 +0,0 @@
.section {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 760px;
color: var(--dsw-alias-label-primary);
}
.status {
margin: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}
.search {
position: relative;
display: flex;
align-items: center;
width: 100%;
color: var(--dsw-alias-label-tertiary);
}
.search > svg {
position: absolute;
left: 12px;
pointer-events: none;
}
.search input {
width: 100%;
height: 32px;
padding: 0 12px 0 36px;
border: 0.5px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font: inherit;
}
.list {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 8px;
}
.row:hover {
background: var(--dsw-alias-bg-layer-1);
}
.identity {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
overflow: hidden;
font-size: 13px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
@@ -1,134 +0,0 @@
/**
* Archived-session Settings page: the registry-global archive set joined with
* the loaded Session summaries, newest archive first, filtered by one search
* box, with one Unarchive action per row. An archive entry whose Session is
* gone has no row and no action; the set itself stays host-owned.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { Button, IconSearchOutlineRegular, relativeTime } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import css from './ArchivedSessionsSection.module.css'
/** Registration-side face used by the page. */
export interface ArchivedSessionsSectionInjected {
/**
* Restore one archived Session.
* @param sessionId - Session to unarchive.
*/
unarchive: (sessionId: SessionId) => Promise<void>
}
/** Full component props assembled by the Settings slot renderer. */
export type ArchivedSessionsSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.archivedSessions'>
& InjectFace<ArchivedSessionsSectionInjected>
type Translate = ArchivedSessionsSectionProps['t']
/** One archived Session as the page renders it. */
interface ArchivedRow {
id: SessionId
title: string
/** Owning Workspace title, or the ungrouped label for a Session outside every Workspace. */
workspace: string
updatedAt: number
}
/** Localized compact relative time of one row's last activity. */
function timeLabel(updatedAt: number, now: number, t: Translate): string {
const { unit, n } = relativeTime(updatedAt, now)
return unit === 'now' ? t('time.now') : t(`time.${unit}`, { n })
}
/** Whether one row matches the normalized query in its title or Workspace label. */
function matches(row: ArchivedRow, normalizedQuery: string): boolean {
return normalizedQuery.length === 0
|| row.title.toLowerCase().includes(normalizedQuery)
|| row.workspace.toLowerCase().includes(normalizedQuery)
}
/**
* Render the archived-session page.
* @param props - composed slot props (see {@link ArchivedSessionsSectionProps}).
* @returns the settings page element tree.
*/
export function ArchivedSessionsSection(props: ArchivedSessionsSectionProps): ReactNode {
const { t, unarchive, useSessions, useWorkspaces } = props
const sessions = useSessions(state => state)
const workspaces = useWorkspaces(state => state.items)
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
const [query, setQuery] = useState('')
const ungrouped = t('ungrouped')
const summaries = sessions.byId
// Archive order is oldest first; the page lists the most recently archived
// Session first. A member with no loaded summary is not addressable here.
const rows = useMemo<ArchivedRow[]>(() => {
const owners = new Map<string, string>()
for (const workspace of workspaces) {
for (const id of workspace.sessionIds) owners.set(id, workspace.title)
}
return [...archivedSessionIds].reverse().flatMap((id) => {
const summary = summaries[id]
if (summary === undefined) return []
return [{
id,
title: summary.displayTitle,
workspace: owners.get(id) ?? ungrouped,
updatedAt: summary.updatedAt,
}]
})
}, [archivedSessionIds, workspaces, summaries, ungrouped])
if (sessions.phase !== 'ready') return <p className={css.status}>{t('loading')}</p>
const now = Date.now()
const visible = rows.filter(row => matches(row, query.trim().toLowerCase()))
const archived = archivedSessionIds.length > 0
return (
<div className={css.section}>
<div className={css.search}>
<IconSearchOutlineRegular aria-hidden="true" />
<input
type="search"
value={query}
placeholder={t('search')}
aria-label={t('search')}
onChange={(event) => { setQuery(event.currentTarget.value) }}
/>
</div>
{!archived ? <p className={css.status}>{t('empty')}</p> : null}
{archived && rows.length === 0 ? <p className={css.status}>{t('unavailable')}</p> : null}
{rows.length > 0 && visible.length === 0 ? <p className={css.status}>{t('emptySearch')}</p> : null}
{visible.length > 0 ? (
<ul className={css.list}>
{visible.map(row => (
<li key={row.id} className={css.row}>
<span className={css.identity}>
<span className={css.title}>{row.title}</span>
<span className={css.meta}>
{[row.workspace, timeLabel(row.updatedAt, now, t)].join(' · ')}
</span>
</span>
<Button
variant="outline"
size="sm"
aria-label={t('unarchiveNamed', { title: row.title })}
onClick={() => {
unarchive(row.id).catch((reason: unknown) => {
console.warn('session unarchive rejected:', reason)
})
}}
>
{t('unarchive')}
</Button>
</li>
))}
</ul>
) : null}
</div>
)
}
@@ -1,49 +0,0 @@
/** Archived-session Settings page, browser half. */
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls the `uiWorkspace` Context merge and the `useWorkspaces`
// global standard prop this page reads.
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
// Type-only: pulls the `useSessions` global standard prop.
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { ArchivedSessionsSection } from './ArchivedSessionsSection.tsx'
import type { ArchivedSessionsSectionInjected } from './ArchivedSessionsSection.tsx'
import { en, zh, type ArchivedSessionsLocaleKey } from './locales.ts'
export type { ArchivedSessionsSectionInjected, ArchivedSessionsSectionProps } from './ArchivedSessionsSection.tsx'
export type { ArchivedSessionsLocaleKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Archived-session page copy. */
'settings.archivedSessions': ArchivedSessionsLocaleKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'settings.archivedSessions'
/** Services required by the Settings registration and the archive write. */
export const inject = ['slots', 'locale', 'uiWorkspace']
/** Contribute the archived-session page to Settings. */
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-unarchive-sessions: dictionaries')
const t = ctx.locale.bind(NS)
const injected = (): ArchivedSessionsSectionInjected => ({
unarchive: sessionId => ctx.uiWorkspace.unarchiveSession(sessionId),
})
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'archived-sessions',
order: 25,
label: () => t('nav'),
locale: NS,
inject: injected,
}, ArchivedSessionsSection))
}
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more