diff --git a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md b/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md deleted file mode 100644 index 3b1e364e3a..0000000000 --- a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md +++ /dev/null @@ -1,45 +0,0 @@ -# Agent Note: Session unarchive Settings page - -Status: implemented - -English | [中文](2026-09-12-session-unarchive-settings-page.zh.md) - -## Problem - -Archiving a Session removed it from every Workspace grouping surface, and nothing brought it back. The archive set is a durable display filter, so a hidden Session kept its log, its Workspace accounting slot, and its position, but the only recovered route was editing the domain state by hand. The archived feature record predicted the restore surface as "one UI surface plus one inverse RPC"; both now exist, and the Client had no place that listed archived Sessions at all. - -## Decision - -`WorkspaceRegistry.unarchiveSession(sessionId)` drops one id from the registry-global `archivedSessionIds` set in a single durable `setState`, running on the same operation chain as archive, create, and delete. The idempotent check-then-write pair sits inside one chain slot, so a concurrent archive cannot interleave and a lost race resolves as a no-op. An id that is not archived resolves without writing. - -Unarchiving runs no session-existence probe. Archive verifies that the Session is live or persisted because it adds a reference that must resolve; unarchive only removes an id, so it cannot introduce an unknown referent and an entry whose Session is gone still restores. `@Remote('unarchiveSession')` on `WorkspaceController` returns the complete `WorkspaceArchiveValue`, matching `archiveSession`, and `IWorkspaces.unarchiveSession` plus `UiWorkspace.unarchiveSession` carry the verb to the browser. Both verbs answer with the complete set, so `ClientWorkspaceModel` installs a reply only while it is still the latest archive-set request: a later request, or a set pushed by the follow stream, supersedes an in-flight answer and keeps the projection on the newer state. - -The new Web Settings page `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` owns the surface. It registers one localized `settings.section` contribution with id `archived-sessions` at nav order 25, joins the archive set from `useWorkspaces` with the loaded Session summaries from `useSessions`, lists the newest archive first with the owning Workspace title or the ungrouped label and a relative last-activity time, filters by title or Workspace name, and offers one Unarchive action per row. A rejected write is logged as a console diagnostic and leaves the row for another attempt. An archive entry whose Session summary is missing produces no row, so the page never renders an action that cannot restore anything. - -Restoring is a page whose subject is archived Sessions, and the archived Sessions themselves are hidden from every grouping surface, so no Session row can host the action. The archived-sessions page joins the Settings navigation beside General, Models, and Plugins, whose rail already carries the archive glyph this page declares in `SettingsRoot`. - -The `{ type: 'archived', archivedSessionIds }` follow increment already carries the complete set, so a restore reuses it: the Remote answer installs locally, and every other Client converges through the same increment. No frame type, persisted field, or `SESSION_FORMAT_VERSION` change accompanies the verb. - -## Alternatives considered - -**An Unarchive action in the Session row menu.** The row menu owns Archive, but a restored Session has no visible row until it is restored, so the action would have nowhere to live in the state that needs it; a disabled entry on every row would be decoration without a subject. - -**A second frame type carrying a removal delta.** The existing `archived` increment is a complete-set replacement, so a removal delta would add a redundant representation that every consumer would have to merge; the full-snapshot posture is what lets the archive echo be reused unchanged. - -**Rendering an archive entry whose Session is gone as a disabled row.** Such a row could explain the gap but not restore anything, and the registry and the Remote still accept the id, so the page lists what it can act on and leaves the orphan id to a future cleanup surface. - -**A Session-existence probe on unarchive, mirroring archive.** The check exists to keep added references resolvable, and a removal cannot break that invariant; probing would only turn restoring a deleted history into a failure with nothing to repair. - -## Consequences - -The archive set stays the only durable state a restore rewrites; the `workspace` domain version, the Session log, and the Workspace accounting slot are untouched, and a restored Session returns to its recorded position. Archive and unarchive now enforce different Session checks, a deliberate asymmetry recorded in the [Workspace registry limitations](../../../../packages/workspace/workspace/README.md#known-limitations-and-deferred-work). - -The restore surface is bounded by what the page can display: an archived id whose summary is not loaded has no row and no Unarchive action even though the registry method and the Remote accept it, so the page reports an unavailable set instead of an empty archive. Restoring through automation stays available, and a future cleanup surface can address the remaining orphan entries. - -## Testing - -`packages/workspace/workspace/tests/workspace.spec.ts` pins the registry method: removal keeps the surviving archive order, the accounting slot stays, a repeat and a never-archived id neither rewrite the medium nor emit a change, an entry whose Session is gone resolves without a persistence listing, and the surviving set reloads across a restart. `packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` pins the verb's idempotent complete-set answer and the unchanged `archived` follow increment, and `packages/api/workspace-controller/tests/model.client.spec.ts` pins the Client model echo and its refusal to install a failed answer, plus the four stale-reply races: overlapping archive or unarchive requests settling out of order, and a follow increment or baseline arriving while a reply is in flight. `packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` pins newest-first ordering, the ungrouped label, the hidden entry whose Session is gone, the reading and empty states, search, and the console diagnostic on rejection, while `tests/browser-plugin.client.spec.tsx` pins the section registration. - -## Related - -- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set, the follow increment, and the predicted restore surface. diff --git a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md b/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md deleted file mode 100644 index 61e108dd50..0000000000 --- a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.zh.md +++ /dev/null @@ -1,45 +0,0 @@ -# Agent Note: Session unarchive Settings page - -Status: implemented - -[English](2026-09-12-session-unarchive-settings-page.md) | 中文 - -## 问题 - -归档会话会把它从每一个 Workspace 分组界面中移除,而没有任何东西能把它带回来。归档集合是持久的显示过滤器,因此被隐藏的会话保留其日志、Workspace 记账位置与所在位置,但唯一的恢复途径是手工编辑领域状态。归档功能记录把恢复界面预测为「一个 UI 界面加一个反向 RPC」;两者如今都已存在,而 Client 此前根本没有列出已归档会话的地方。 - -## 决策 - -`WorkspaceRegistry.unarchiveSession(sessionId)` 在一次持久化 `setState` 中把某个 id 从注册表全局的 `archivedSessionIds` 集合里移除,并与 archive、create、delete 跑在同一条操作链上。幂等的「先检查再写入」位于同一个链槽内,因此并发归档无法插入其间,输掉竞态的一方会解析为无操作。未被归档的 id 不写盘即解析完成。 - -取消归档不做会话存在性探测。归档会校验会话处于实时或已持久化状态,因为它加入的引用必须可解析;取消归档只是移除一个 id,因此不可能引入未知引用,会话已不存在的条目也仍然能恢复。`WorkspaceController` 上的 `@Remote('unarchiveSession')` 返回完整的 `WorkspaceArchiveValue`,与 `archiveSession` 一致,而 `IWorkspaces.unarchiveSession` 与 `UiWorkspace.unarchiveSession` 把该动词带到浏览器。两个动词都返回完整集合,因此 `ClientWorkspaceModel` 只在该应答仍是最新归档集合请求时安装它:更晚的请求或 follow 流推送的集合都会取代在途应答,让投影停留在更新的状态上。 - -新的 Web 设置页 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` 拥有该界面。它注册一个 id 为 `archived-sessions`、导航顺序为 25 的本地化 `settings.section` 贡献,把来自 `useWorkspaces` 的归档集合与来自 `useSessions` 的已加载 Session 摘要合并,按归档时间由新到旧列出,并显示为其记账的 Workspace 标题或未分组标签,以及相对最近活动时间;它按标题或 Workspace 名称过滤,并为每行提供一个取消归档操作。写入被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。缺失 Session 摘要的归档条目不产生行,因此该页面绝不会渲染无法恢复任何东西的操作。 - -恢复操作所在的页面以已归档会话为主题,而已归档会话本身从每一个分组界面中隐藏,因此没有任何 Session 行能承载该操作。「已归档会话」页在设置导航中与「通用」「模型」「插件」并列,而该导航轨道本就带有本页在 `SettingsRoot` 中声明的归档字形。 - -`{ type: 'archived', archivedSessionIds }` follow 增量本就携带完整集合,因此恢复复用它:Remote 应答在本地安装,其他每个 Client 都通过同一个增量收敛。该动词不伴随任何帧类型、持久字段或 `SESSION_FORMAT_VERSION` 变更。 - -## 考虑过的替代方案 - -**在 Session 行菜单放一个取消归档操作。** 行菜单拥有 Archive,但被恢复的会话在恢复之前没有可见的行,因此该操作在真正需要它的状态里无处安放;给每一行加一个禁用条目只是没有主体的装饰。 - -**新增一种携带移除增量的帧类型。** 现有的 `archived` 增量是完整集合替换,因此移除增量会增加一种每个消费方都必须合并的冗余表示;正是全快照姿态让归档回声得以原样复用。 - -**把会话已不存在的归档条目渲染为禁用行。** 这样的行可以解释缺口,却无法恢复任何东西,而注册表与 Remote 仍然接受该 id,因此该页面只列出它能操作的条目,把遗留 id 留给未来的清理界面。 - -**像归档一样,在取消归档时做会话存在性探测。** 该检查的存在是为了让新增的引用保持可解析,而移除不可能破坏这一不变式;探测只会把恢复已删除的历史变成一次无从修复的失败。 - -## 后果 - -归档集合仍是恢复唯一重写的持久状态;`workspace` 领域版本、会话日志与 Workspace 记账位置都不受影响,被恢复的会话回到其记录的位置。归档与取消归档现在执行不同的会话校验,这是刻意的不对称,并记录在 [Workspace registry 的限制](../../../../packages/workspace/workspace/README.zh.md#known-limitations-and-deferred-work)中。 - -恢复界面受该页面能显示的内容限制:摘要未加载的已归档 id 没有行也没有取消归档操作,尽管注册表方法与 Remote 都接受它;因此页面报告没有可恢复条目,而不是把归档集合说成空的。通过自动化恢复仍然可用,未来的清理界面可以处理剩余的遗留条目。 - -## 测试 - -`packages/workspace/workspace/tests/workspace.spec.ts` 固定注册表方法:移除后幸存的归档顺序保持不变、记账位置仍在、重复取消归档与从未归档的 id 既不重写介质也不发出变更、会话已不存在的条目无需持久化列表即可解析、幸存集合在重启后重新加载。`packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` 固定该动词幂等的完整集合应答与不变的 `archived` follow 增量,`packages/api/workspace-controller/tests/model.client.spec.ts` 固定 Client model 的回声及其拒绝安装失败应答的行为,以及四种过期应答竞态:归档或取消归档请求重叠且乱序返回,以及应答在途时到达的 follow 增量或 baseline。`packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` 固定由新到旧的排序、未分组标签、会话已不存在条目的隐藏、读取与空状态、搜索,以及被拒绝时的 console 诊断,`tests/browser-plugin.client.spec.tsx` 则固定分区注册。 - -## 相关 - -- [会话归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md)——归档集合、follow 增量与被预测的恢复界面的冻结记录。 diff --git a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml similarity index 55% rename from .agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml rename to .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml index 38e3528468..2d995ebcf4 100644 --- a/.agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md new file mode 100644 index 0000000000..eb0c82ba84 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md new file mode 100644 index 0000000000..d971c7b4ff --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md @@ -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 增量的冻结记录。 diff --git a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml index 1fe1deceac..6895faecfb 100644 --- a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md -2026-09-10-derived-workspace-recency.md: e8f304110c89bf563164dd24125beeb36e7644a6 -2026-09-10-derived-workspace-recency.zh.md: 60f2da36c277e77d367f152aa8a3159ff68d909c +2026-09-10-derived-workspace-recency.md: b967e332f234d6d120bb82c444e4b05818b85103 +2026-09-10-derived-workspace-recency.zh.md: 87c2bb9f2648baa613ded32cda5b8673a77c5f56 diff --git a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md index e8f304110c..b967e332f2 100644 --- a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md +++ b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md @@ -12,7 +12,7 @@ An editable activity-promoted list can disagree with its displayed timestamps wi Last updated is a pure projection of current Session summaries: ordinary rows sort by descending `updatedAt`, with Session id as the tie-break. The selected blank New Session precedes ordinary rows until its first prompt. Grouped, Ungrouped, and flat views use the same policy. Reloads and delayed summaries require no observed-timestamp history or promotion events in the view store. -Only Manual uses browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. Reconciliation retains saved members that remain in the Workspace account, removes departed members, and appends newly known members at the end, newest first when several arrive together. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation. +Manual reads browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. The [Session pinning and archive rules](../feature/2026-09-18-session-pin-and-sidebar-archive.md) own missing-member supplementation and keep archive visibility out of saved membership. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation. The selected blank New Session is a display invariant applied after either base order: it remains first and cannot start a drag. Manual persists the observed blank position even while the Workspace stream reconnects, preserving every other saved member until the complete baseline permits reconciliation. Waiting to record that position would lose it if the first prompt arrives before the baseline. Its first prompt removes the blank state. Manual then keeps the existing first slot and allows dragging; Last updated projects it from the prompt timestamp. The ordering subscription and reconciliation remain mounted when the sidebar is collapsed or search replaces the list body. diff --git a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md index 60f2da36c2..87c2bb9f26 100644 --- a/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md @@ -12,7 +12,7 @@ Status: implemented 最近更新是当前 Session 摘要的纯投影:普通行按 `updatedAt` 降序排列,时间相同时按 Session id 排序。当前选中的空白新会话在首条提示词落地前排在普通行之前。分组、Ungrouped 和单列表视图采用相同策略。重新加载和摘要迟到不需要视图存储中的已观察时间历史或提升事件。 -只有手动排序使用浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。对账会保留仍属于 Workspace 记账的已保存成员、移除已经离开的成员,并把新发现的成员追加到末尾;多条同时到达时按最近更新时间降序排列。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。 +手动排序读取浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。[会话置顶与归档规则](../feature/2026-09-18-session-pin-and-sidebar-archive.zh.md)说明缺失成员补齐,并让归档显隐不影响保存的成员关系。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。 当前选中的空白新会话是在两种基础顺序之后应用的显示不变式:它保持首位且无法发起拖拽。即使 Workspace 流正在重连,手动排序也会持久化已观察到的空白行位置,并保留其他所有已保存成员,直到完整基线允许对账。若等待基线后才记录该位置,首条提示词先到达时就会丢失它。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。 diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 36eab3cb42..913b082ed3 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -88,6 +88,7 @@ interface CapturedFixture { readonly value: { readonly items: readonly WorkspaceView[] readonly archivedSessionIds: readonly string[] + readonly pinnedSessionIds: readonly string[] } } readonly control: ControlBaseline @@ -186,6 +187,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass value: { items: structuredClone(workspaces), archivedSessionIds: structuredClone(fixture.workspace.value.archivedSessionIds), + pinnedSessionIds: structuredClone(fixture.workspace.value.pinnedSessionIds), }, }) }) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 33c8046779..fe8a284967 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -83,7 +83,7 @@ async function openSeed(page: Page): Promise { // 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 }) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 17485b10bc..2a13b8ef3e 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -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 diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts index ee8c9a07df..9eda0bb603 100644 --- a/apps/web/tests/composer-tab-geometry.e2e.ts +++ b/apps/web/tests/composer-tab-geometry.e2e.ts @@ -192,7 +192,7 @@ async function openSeededSession(page: Page): Promise { // 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 diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index 033bf298ae..f55d4098d2 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index c28c733b10..2d4db679fb 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 471f6bbada..077c88b625 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 2a879d6b31..7df4c7ce8a 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 610d173e1e..6618682d61 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -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" --- diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 7167daea13..87b48ef41c 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -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" --- diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md index 782e523ddb..69e631f4cd 100644 --- a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index aa803a766c..e30e6cc2dd 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index 3758c2af62..f294ff5977 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index e8b2579203..fcaeb76e56 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index 4844a60cfa..d752bbb42a 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index 6a369b9267..8ce2f31098 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 4920e310b8..be77e20835 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 8bccdd9d12..9e344cc148 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 76ba608af1..0279600e91 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - heading "模型" [level=2] diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index ace79f9802..41d6462e11 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -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 diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 212fabd235..9a3776bcb7 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -5,7 +5,6 @@ - button "模型" - button "内置插件" - button "Agent 预设" - - button "已归档会话" - button "打开配置文件" - button "关闭" - text: 权限 选择新会话的默认权限模式 diff --git a/apps/web/tests/expected/workspace-management/grouping-options.expected.md b/apps/web/tests/expected/workspace-management/grouping-options.expected.md index 4652128ef7..2a91c7813d 100644 --- a/apps/web/tests/expected/workspace-management/grouping-options.expected.md +++ b/apps/web/tests/expected/workspace-management/grouping-options.expected.md @@ -7,3 +7,7 @@ - text: Order by - menuitem "Manual" - menuitem "Last updated" + - separator + - text: Filter sessions + - menuitem "Show archived" + - menuitem "Archived only" diff --git a/apps/web/tests/fixtures/assembled-remote.fixture.json b/apps/web/tests/fixtures/assembled-remote.fixture.json index 34d3112dfe..e147b1f192 100644 --- a/apps/web/tests/fixtures/assembled-remote.fixture.json +++ b/apps/web/tests/fixtures/assembled-remote.fixture.json @@ -253,7 +253,8 @@ "updatedAt": "2026-09-13T03:02:55.884Z" } ], - "archivedSessionIds": [] + "archivedSessionIds": [], + "pinnedSessionIds": [] } }, "control": { diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 85a16c58a5..2530ec0497 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -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) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 22d35431e1..4ab75b3ba0 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -62,7 +62,7 @@ async function ensureSeedOpen(page: Page): Promise { // 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') diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts index 6069367555..3cefc1f0d4 100644 --- a/apps/web/tests/pwsh-terminal.e2e.ts +++ b/apps/web/tests/pwsh-terminal.e2e.ts @@ -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) diff --git a/apps/web/tests/rail-search-expand.e2e.ts b/apps/web/tests/rail-search-expand.e2e.ts index df0130ff67..9070437dcb 100644 --- a/apps/web/tests/rail-search-expand.e2e.ts +++ b/apps/web/tests/rail-search-expand.e2e.ts @@ -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 }, diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index f20752bece..8b3e954d58 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -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) }) diff --git a/apps/web/tests/session-unarchive.e2e.ts b/apps/web/tests/session-unarchive.e2e.ts index 81fd5da3cb..068f5c0b6b 100644 --- a/apps/web/tests/session-unarchive.e2e.ts +++ b/apps/web/tests/session-unarchive.e2e.ts @@ -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) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 4530b1f040..d78c935cec 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -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( diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index 20a8ee340c..31a2c773db 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -71,7 +71,7 @@ async function openSeed(page: Page): Promise { // 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) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ba1ff6a95c..7733f352e1 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: c439dcf876922bc01d0ac4872f2101246ee53ed1 -config-catalog.zh.md: e3d76addd80421759db5c6ec896d9837a05532b4 +config-catalog.md: d802c5f084072dbe4f6654aecaf0640be96fb4bf +config-catalog.zh.md: b7c3b4be06b9ddf3fcd4117483fb0b1472493441 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c439dcf876..d802c5f084 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e3d76addd8..b7c3b4be06 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 78f2897d67..4a2feec24a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 2e2899c3a74e1d2c5fe50ab137408e7ed3beca82 -module-graph.zh.md: 49291ba55842462e5d532cce1de4f3803610d908 +module-graph.md: 90c9601b329daa91f21713a20cf499c9937e6d34 +module-graph.zh.md: 19e6bd1b942ae3319f137b7528fea4b69383d0aa diff --git a/docs/module-graph.md b/docs/module-graph.md index 2e2899c3a7..90c9601b32 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 49291ba558..19e6bd1b94 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -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` | — | diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 2594960acb..0dfc4fa69c 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-client.md -web-client.md: 59bf663864b367382aedb1352ee3ca38f3e651ba -web-client.zh.md: f38ef5e2c9d2258be765d8a2bb400751d72948c8 +web-client.md: 6bcf9bf84ed0b049b801c38d2426074d784e8de6 +web-client.zh.md: 9c697964a0f92bc646f5ca305fc3372bebcdc902 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 59bf663864..6bcf9bf84e 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -47,7 +47,7 @@ The durable event path opens `follow()`, whose first frame contains the current ### Workspaces -[`api/workspace-controller`](../../packages/api/workspace-controller/README.md) keeps Workspace mutation policy and the authoritative follow feed on the Host. `ClientWorkspaceModel` owns the browser rows, order, archived Session ids, command echoes, and stream/unary race resolution. Every stream generation starts with a complete baseline followed by `upsert`, `remove`, `order`, and `archived` increments; reconnect replaces the model from the new baseline. `WorkspaceController` exposes that model as `ctx.workspaces`, while `ui-workspace` contributes `useWorkspaces` and navigation callbacks to the UI. The archived Session ids filter every grouping surface and feed the archived-sessions Settings page, which joins the set with the loaded Session summaries and offers one Unarchive action per row; a restore calls the `workspace.unarchiveSession` Remote, and the complete returned set reaches every Client through the `archived` increment. +[`api/workspace-controller`](../../packages/api/workspace-controller/README.md) keeps Workspace mutation policy and the authoritative follow feed on the Host. `ClientWorkspaceModel` owns browser rows, Workspace order, archived and pinned Session id arrays, command echoes, and stream/unary race resolution. Every stream generation starts with a complete baseline followed by `upsert`, `remove`, `order`, `archived`, and `pinned` increments; reconnect replaces the model from the new baseline. `WorkspaceController` exposes that model as `ctx.workspaces`, while `ui-workspace` contributes `useWorkspaces` and navigation callbacks. The sidebar's `ArchivedFilter` controls default-hidden, shown, or archived-only rows in lists and search. Archived rows retain their ordering slots, render grayed, and cannot open until restored through the row or search-result Unarchive action. Restoration calls `workspace.unarchiveSession`, and its complete archive set reaches Clients through the unary response and `archived` increment. Session display order stays browser-local and includes hidden archives; pinning moves a Session within that full order, while unpinning does not restore its earlier position. This pairing is not a second source of business truth. Host controllers decide durable state and mutation outcomes; Client models maintain the latest usable local projection, preserve object identity where useful to rendering, and encode how delayed responses and replacement baselines merge. diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index f38ef5e2c9..9c697964a0 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -47,7 +47,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe ### Workspaces -[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace mutation policy 与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器侧 row、order、archived Session id、command echo,以及 stream/unary 竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order` 和 `archived` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,而 `ui-workspace` 向 UI 提供 `useWorkspaces` 与 navigation callback。archived Session id 过滤每一个分组视图,并驱动「已归档会话」设置页;该页把该集合与已加载的 Session summary 合并,为每行提供一个取消归档操作。恢复会调用 `workspace.unarchiveSession` Remote,返回的完整集合则经 `archived` increment 到达每个 Client。 +[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace 变更策略与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器行、Workspace 顺序、归档与置顶 Session id 数组、命令回显,以及流与一元调用的竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order`、`archived` 和 `pinned` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,`ui-workspace` 则提供 `useWorkspaces` 与导航回调。侧边栏的 `ArchivedFilter` 控制列表与搜索中的默认隐藏、显示已归档或仅显示已归档。归档行保留排序位置、置灰展示,必须通过行菜单或搜索结果的取消归档操作恢复后才能打开。恢复调用 `workspace.unarchiveSession`,完整归档集合通过一元响应及 `archived` increment 到达 Client。Session 显示顺序保存在浏览器本地,包含隐藏归档项;置顶在该完整顺序内移动 Session,取消置顶不恢复先前位置。 这种配对不会产生第二份业务真相。Host controller 决定持久状态与 mutation outcome;Client model 维护最新可用的本地 projection,在有利于渲染时保持 object identity,并明确 delayed response 与 replacement baseline 的合并规则。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 36c4adcd78..db7bb548ef 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 8ecf54a2860ab5d442c4856f6efd03900de1ce9b -workspace.zh.md: dd4aed5f271aff0256115b03c85ec8b50ad0c502 +workspace.md: 9fe5f2bf4c62ba7d4f878b7b2f34490f43ca0b81 +workspace.zh.md: 63681e1a459c81a327654cfafa1f5cb2f231d6c8 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 8ecf54a286..9fe5f2bf4c 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -121,6 +121,10 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`. +## Session pinning + +The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspacePinSessionRequest` and `WorkspaceUnpinSessionRequest`, each carrying one `sessionId`. Both operations return `WorkspacePinValue`: the complete `pinnedSessionIds` array of Session ids, most recently pinned first. Pinning requires a known, unarchived Session; unpinning an id that is not pinned succeeds without changing the set. Archiving removes the Session's pin in the same durable write, and unarchiving does not restore it. + ## Consumers [`dsh-workspace-controller`](../../packages/api/workspace-controller) serves workspace CRUD to GUI clients over `ctx.workspaceRegistry`, and [`dsh-session-controller`](../../packages/api/session-controller) performs the create-session-then-attach flow above. [dsh-agent-instructions](../../packages/context/agent-instructions) is **not** a consumer despite the name: it discovers AGENTS.md-style instruction files under an agent's own cwd and never touches `ctx.workspaceRegistry` — the shared word refers to the user's working directory, not to this registry's entities. @@ -339,6 +343,20 @@ Host service backing the generated `ctx.remote.workspace` namespace. */ @Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise +/** + * Surface one known unarchived Session ahead of unpinned Sessions. + * @param request - Session identity to pin. + * @returns the complete resulting pin set, most recently pinned first. + */ +@Remote('pinSession') pinSession(request: WorkspacePinSessionRequest): Promise + +/** + * Remove one Session's pin without changing its saved Session order. + * @param request - Session identity to unpin. + * @returns the complete resulting pin set, most recently pinned first. + */ +@Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise + /** * Stream a complete Workspace baseline followed by ordered increments. * @param signal - generation cancellation. @@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise */ unarchiveSession(sessionId: SessionId): Promise +/** + * Pin one session durably, prepending it to the registry-global pin set. + * The session must exist (live or in session persistence) and must not be + * archived. An already pinned id resolves without writing or reordering. + * @param sessionId - The session to pin. + * @returns resolution after durability. + */ +pinSession(sessionId: SessionId): Promise + +/** + * Unpin one session durably by dropping it from the registry-global pin + * set. Unpinning runs no session-existence check because removing an id + * cannot introduce an unknown one, so an entry whose session is gone still + * resolves. An id that is not pinned resolves without writing. + * @param sessionId - The session to unpin. + * @returns resolution after durability. + */ +unpinSession(sessionId: SessionId): Promise + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index dd4aed5f27..63681e1a45 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -121,6 +121,10 @@ interface Workspace { 会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。 +## 会话置顶 + +控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspacePinSessionRequest` 和 `WorkspaceUnpinSessionRequest`,两者都携带一个 `sessionId`。两个操作都返回 `WorkspacePinValue`:完整的会话 id 数组 `pinnedSessionIds`,最近置顶的会话排在前面。置顶要求会话已知且未归档;对未置顶的 id 取消置顶会成功,且不改变集合。归档在同一次持久化写入中移除该会话的置顶,取消归档不会恢复置顶。 + ## 消费方 [`dsh-workspace-controller`](../../packages/api/workspace-controller) 经 `ctx.workspaceRegistry` 向 GUI 客户端提供工作区 CRUD,[`dsh-session-controller`](../../packages/api/session-controller) 执行上文「先建会话再 attach」的流程。[dsh-agent-instructions](../../packages/context/agent-instructions) 尽管名字如此,却**不是**消费方:它在 agent 自己的 cwd 下发现 AGENTS.md 风格的指令文件,从不触碰 `ctx.workspaceRegistry`——两者共用的这个词指的是用户的工作目录,而非本注册表的实体。 @@ -339,6 +343,20 @@ Host service backing the generated `ctx.remote.workspace` namespace. */ @Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise +/** + * Surface one known unarchived Session ahead of unpinned Sessions. + * @param request - Session identity to pin. + * @returns the complete resulting pin set, most recently pinned first. + */ +@Remote('pinSession') pinSession(request: WorkspacePinSessionRequest): Promise + +/** + * Remove one Session's pin without changing its saved Session order. + * @param request - Session identity to unpin. + * @returns the complete resulting pin set, most recently pinned first. + */ +@Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise + /** * Stream a complete Workspace baseline followed by ordered increments. * @param signal - generation cancellation. @@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise */ unarchiveSession(sessionId: SessionId): Promise +/** + * Pin one session durably, prepending it to the registry-global pin set. + * The session must exist (live or in session persistence) and must not be + * archived. An already pinned id resolves without writing or reordering. + * @param sessionId - The session to pin. + * @returns resolution after durability. + */ +pinSession(sessionId: SessionId): Promise + +/** + * Unpin one session durably by dropping it from the registry-global pin + * set. Unpinning runs no session-existence check because removing an id + * cannot introduce an unknown one, so an entry whose session is gone still + * resolves. An id that is not pinned resolves without writing. + * @param sessionId - The session to unpin. + * @returns resolution after durability. + */ +unpinSession(sessionId: SessionId): Promise + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index c5e7c8f116..cd584226a9 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md -README.md: ccf07bfb61ad005d516928ad27145148a62ddf19 -README.zh.md: b96f49e92696cef2475458f4b51c764063aa32f9 +README.md: 46556b0105bb5186cf27b56faee4127b358eac9b +README.zh.md: 9d210c10486a71772cf6c0a10480b734fa992441 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index ccf07bfb61..46556b0105 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -22,9 +22,9 @@ English | [中文](README.zh.md) ## Use this package -The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable `workspace/*` or `directory-picker/*` code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. +The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. -The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived Session ids, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. +The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. ----- diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index b96f49e926..9d210c1048 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -22,9 +22,9 @@ kind: "package-reference" ## 使用本包 -Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定 `workspace/*` 或 `directory-picker/*` 错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。 +Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。 -Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、已归档 Session id、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。 +Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。 ----- diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index fffa05c18f..9a1af4c2e7 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -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) diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index 1921e9247f..570b70aeb3 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -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() 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> { + 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> { + 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])) diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index 9f44214801..00998f8519 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -67,6 +67,16 @@ export interface IWorkspaces { * @param sessionId - Session to unarchive. */ unarchiveSession(sessionId: SessionId): Promise + /** + * Pin a Session ahead of unpinned Sessions on Workspace grouping surfaces. + * @param sessionId - Session to pin. + */ + pinSession(sessionId: SessionId): Promise + /** + * Remove a Session's pin without changing its saved Session order. + * @param sessionId - Session to unpin. + */ + unpinSession(sessionId: SessionId): Promise /** * 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 { + const result = await this.model.pinSession(sessionId) + if (!result.ok) throw commandError('session pin', result.error) + } + + async unpinSession(sessionId: SessionId): Promise { + const result = await this.model.unpinSession(sessionId) + if (!result.ok) throw commandError('session unpin', result.error) + } + async insertSessionBefore( workspaceId: WorkspaceId, sessionId: SessionId, diff --git a/packages/api/workspace-controller/src/commands.ts b/packages/api/workspace-controller/src/commands.ts index 642c7e7da1..d69d269dfb 100644 --- a/packages/api/workspace-controller/src/commands.ts +++ b/packages/api/workspace-controller/src/commands.ts @@ -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 { + 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 { + 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) diff --git a/packages/api/workspace-controller/src/feed.ts b/packages/api/workspace-controller/src/feed.ts index 57d38f18e5..709f17fa5d 100644 --- a/packages/api/workspace-controller/src/feed.ts +++ b/packages/api/workspace-controller/src/feed.ts @@ -49,6 +49,7 @@ export class WorkspaceFeed { private knownIds: Set 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 diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 2db218df8c..deb547e791 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -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 { + 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 { + return this.commands.unpinSession(request) + } + /** * Stream a complete Workspace baseline followed by ordered increments. * @param signal - generation cancellation. diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index 15678e9a8f..f2b78e024b 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -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 = diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index 21d42f81f7..b126805183 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -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> = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] })) + onPinSession: ( + request: WorkspacePinSessionRequest, + ) => Promise> = request => + Promise.resolve(remoteOk({ pinnedSessionIds: [request.sessionId] })) + onUnpinSession: ( + _request: WorkspaceUnpinSessionRequest, + ) => Promise> = () => + Promise.resolve(remoteOk({ pinnedSessionIds: [] })) create(request: WorkspaceCreateRequest): Promise> { this.record('create', request) @@ -125,6 +136,16 @@ class FakeWorkspaceRemote implements WorkspaceRemote { return this.onUnarchiveSession(request) } + pinSession(request: WorkspacePinSessionRequest): Promise> { + this.record('pinSession', request) + return this.onPinSession(request) + } + + unpinSession(request: WorkspaceUnpinSessionRequest): Promise> { + this.record('unpinSession', request) + return this.onUnpinSession(request) + } + async *follow(_signal?: AbortSignal): AsyncGenerator {} 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>() + const secondGate = deferred>() + 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>() + 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, [ diff --git a/packages/api/workspace-controller/tests/remote/workspace.client.ts b/packages/api/workspace-controller/tests/remote/workspace.client.ts index c1324a4ce8..6857e8e31d 100644 --- a/packages/api/workspace-controller/tests/remote/workspace.client.ts +++ b/packages/api/workspace-controller/tests/remote/workspace.client.ts @@ -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 = {}): 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 => ok({ archivedSessionIds: [request.sessionId] }), 'workspace/unarchiveSession': (_request: WorkspaceUnarchiveSessionRequest): RemoteResult => ok({ archivedSessionIds: [] }), + 'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult => ok({ pinnedSessionIds: [request.sessionId] }), + 'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult => ok({ pinnedSessionIds: [] }), }, } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 67cc99c985..b99406b00c 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -58,6 +58,7 @@ function accepts(overrides: Partial = {}): 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() @@ -160,20 +162,22 @@ describe('Workspace state stream', () => { const removeView = vi.fn() const replaceOrder = vi.fn() const replaceArchived = vi.fn() + const replacePinned = vi.fn() 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') }, ))) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 031c5d0fc3..722b132255 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -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], diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 9fd0effbc5..1f4d203152 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -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' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 6a322911e9..cc00c61d15 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -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:^", diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index cb4a978ab0..7c2a0ec626 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -25,7 +25,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 9db032dc27..94b511a59a 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -212,7 +212,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index f3ac40e704..ad2896b854 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -22,7 +22,7 @@ function emptySessions() { function emptyWorkspaces() { return bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })) } diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 8ddb46f500..48f7ee1f62 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -28,7 +28,7 @@ function emptySessions() { function emptyWorkspaces() { return bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })) } diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 4123e7eee3..74140e0946 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -178,7 +178,7 @@ function bench(over?: BenchOptions) { subagentsByParent: {}, jobsBySession: {}, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })), useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 96de6a18d0..6b65e8aeb0 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -62,7 +62,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSessionRetainInfo: () => undefined, useResource, useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 674cf3dc0a..44b4a48ade 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -156,7 +156,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useSessionRetainInfo: () => undefined, useResource, useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b00138fa9c..fdf57b49c7 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -111,7 +111,7 @@ function workspace(id = 'w1'): WorkspaceView { } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({ - items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) function sessionSnapshotOf(overrides: Partial = {}): SessionSnapshot { diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index b7bf4e10e1..ba7061c7a2 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -81,7 +81,7 @@ function mountFrame(windowWidth = frameWidth) { jobsBySession: {}, }) const workspaceState: WorkspaceSnapshot = { - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, ...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }), } const useStore = bindSnapshotSelector(instance) diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css index bd0fe184c6..af2cacebe7 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -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 { diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx b/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx index 423ce25bcd..785c5e2af7 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx @@ -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({ } + tone="success" anchor={card} onDone={onToastDone} /> diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index f5687d8269..82bd1b374e 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 09c56614799b30c2c6829620bbf14d7cc55cb0b0 -README.zh.md: ced758f9b43605365b43a62363bac0b0ca8a6df2 +README.md: f1267219e6bc50bccdd2dd65771ac8577ddc2e26 +README.zh.md: 15c327c3bd756d8d0684c15c858d4443008d03cc diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 09c5661479..f1267219e6 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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 diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index ced758f9b4..15c327c3bd 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -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 输出 diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index eef5526a1b..18600736eb 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -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. diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index bd56ee1309..1a759c7b20 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -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(null) const listRef = useRef(null) @@ -404,7 +409,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o const list = open && (
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 && {icon}} - {text} + {tone === 'success' + ? + : icon !== undefined && {icon}} + + {text} + {actions?.map(action => ( + + {action.prefix} + + + ))} +
, document.body, ) diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css index 031c45f02f..926fb68557 100644 --- a/packages/client/ui-primitives/src/Tooltip.module.css +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -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; } } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 41baf5b785..688a411fb9 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -20,25 +20,43 @@ interface AnchorProps { ref?: Ref | 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 }) { +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 }) { const anchor = useRef(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" > diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 749f203439..4f926c7e00 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -96,9 +96,9 @@ export const IconPanelLeftOutlineMedium = (props: IconProps) => ( const IconEllipsisOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( ) @@ -1403,3 +1403,175 @@ export const IconShieldOutlineRegular = (props: IconProps) => ( export const IconShieldOutlineMedium = (props: IconProps) => ( ) + +const IconCheckCircleOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconCheckCircleOutline artwork. */ +export const IconCheckCircleOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconCheckCircleOutline artwork with a 1.3px stroke. */ +export const IconCheckCircleOutlineMedium = (props: IconProps) => ( + +) + +const IconUnarchiveOutlineArtwork = ({ size = 20, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconUnarchiveOutline artwork. */ +export const IconUnarchiveOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconUnarchiveOutline artwork with a 1.3px stroke. */ +export const IconUnarchiveOutlineMedium = (props: IconProps) => ( + +) + +const IconPinOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconPinOutline artwork. */ +export const IconPinOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconPinOutline artwork with a 1.3px stroke. */ +export const IconPinOutlineMedium = (props: IconProps) => ( + +) + +const IconPinFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconPinFill artwork. */ +export const IconPinFillRegular = (props: IconProps) => ( + +) + +/** Medium IconPinFill artwork with a 1.3px stroke. */ +export const IconPinFillMedium = (props: IconProps) => ( + +) + +const IconFlatListOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconFlatListOutline artwork. */ +export const IconFlatListOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconFlatListOutline artwork with a 1.3px stroke. */ +export const IconFlatListOutlineMedium = (props: IconProps) => ( + +) + +const IconWorkspaceTreeOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconWorkspaceTreeOutline artwork. */ +export const IconWorkspaceTreeOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconWorkspaceTreeOutline artwork with a 1.3px stroke. */ +export const IconWorkspaceTreeOutlineMedium = (props: IconProps) => ( + +) + +const IconChevronsUpDownOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconChevronsUpDownOutline artwork. */ +export const IconChevronsUpDownOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconChevronsUpDownOutline artwork with a 1.3px stroke. */ +export const IconChevronsUpDownOutlineMedium = (props: IconProps) => ( + +) + +const IconArchiveCheckOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconArchiveCheckOutline artwork. */ +export const IconArchiveCheckOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconArchiveCheckOutline artwork with a 1.3px stroke. */ +export const IconArchiveCheckOutlineMedium = (props: IconProps) => ( + +) + +const IconSlidersTwoOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconSlidersTwoOutline artwork. */ +export const IconSlidersTwoOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconSlidersTwoOutline artwork with a 1.3px stroke. */ +export const IconSlidersTwoOutlineMedium = (props: IconProps) => ( + +) diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 0287450115..89b17cb4bf 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -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)) diff --git a/packages/client/ui-primitives/tests/toast.client.spec.tsx b/packages/client/ui-primitives/tests/toast.client.spec.tsx index 4064d1d7ac..375b2a5aae 100644 --- a/packages/client/ui-primitives/tests/toast.client.spec.tsx +++ b/packages/client/ui-primitives/tests/toast.client.spec.tsx @@ -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() + vi.advanceTimersByTime(1000) + view.rerender() + 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( + , + ) + // 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 { diff --git a/packages/client/ui-primitives/tests/tooltip-node.client.spec.ts b/packages/client/ui-primitives/tests/tooltip-node.client.spec.ts new file mode 100644 index 0000000000..c0026ecdbd --- /dev/null +++ b/packages/client/ui-primitives/tests/tooltip-node.client.spec.ts @@ -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') + }) +}) diff --git a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index 26f66958ab..3f1c4f059c 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -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( + + + , + ) + 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( @@ -257,23 +274,66 @@ describe('Tooltip', () => { } }) + it('a click on the anchor dismisses the bubble even while the anchor stays focused', () => { + render( + + + , + ) + 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( + + + , + ) + 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( - + , ) 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() }) diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index bd24d3cd15..f0ed057d43 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -37,10 +37,10 @@ const CHILD_NAMES = Object.keys(CHILD_SPECS) as Array /** * 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 }, diff --git a/packages/client/ui-settings-unarchive-sessions/README.i18n.yaml b/packages/client/ui-settings-unarchive-sessions/README.i18n.yaml deleted file mode 100644 index 2a027aaa3f..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/client/ui-settings-unarchive-sessions/README.md -README.md: 88a5835c2bf2a2e4799ee02e94985db9162e4ef0 -README.zh.md: 9e2a907d75a84a4e3262fc157023d26f93919a5d diff --git a/packages/client/ui-settings-unarchive-sessions/README.md b/packages/client/ui-settings-unarchive-sessions/README.md deleted file mode 100644 index 88a5835c2b..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/README.md +++ /dev/null @@ -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) - ------ - - -## 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. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -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 | - -
- ------ - - -## 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. - ------ - - -## 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 - - - - -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. - - -### Dev Note - -
-Working context for maintainers — click to expand - -None. - -
- -**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. diff --git a/packages/client/ui-settings-unarchive-sessions/README.zh.md b/packages/client/ui-settings-unarchive-sessions/README.zh.md deleted file mode 100644 index 9e2a907d75..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/README.zh.md +++ /dev/null @@ -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) - ------ - - -## 使用本包 - -打开设置并选择**已归档会话**,即可看到当前从所有分组视图中隐藏的会话。在已提供设置外壳、Workspace 服务与 Session 列表的 Web 组合中挂载 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`;该页面注册自己的导航条目,无需配置。 - -### 阅读一行 - -每行显示会话的显示标题、为其记账的 Workspace 标题或未分组标签,以及以紧凑相对时间表示的最近活动。页面把最近归档的会话排在前面,与持久归档顺序相反。页面会先等待 Session 列表再渲染行;列表加载期间显示读取状态,而不是空归档。空归档、归档条目均无已加载 Session 可恢复、以及查询无匹配分别给出三种提示,因此搜索和不可寻址的条目都不会被误认为归档为空。 - -### 恢复会话 - -取消归档会把会话恢复到其 Workspace 下记录的位置;会话不属于任何 Workspace 时则恢复到未分组会话中,该行随即从页面消失。该操作调用 `ctx.uiWorkspace.unarchiveSession`,其回传的完整归档集合会更新所有依据它过滤的界面,因此会话也会重新出现在侧边栏与搜索中。调用被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -页面是 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) | 页面样式 | - -
- ------ - - -## 进一步探索 - -以下页面覆盖承载该页面的设置界面、其背后的归档写入,以及它所渲染的状态。 - -- [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)——持久归档集合、其领域字段,以及恢复背后的注册表操作。 - ------ - - -## 模型体验 - -无。该包是浏览器端 UI 插件层,不注册任何面向模型的内容。 - -#### KV Cache 影响 - -无;该包既不组装也不发送提供方请求。 - -## 已知限制与延期工作 - - - - -这些限制界定本页面能够恢复哪些已归档会话;它们是当前包约束。 - -- **已加载摘要缺失的已归档会话无法寻址**:页面通过与 Session 列表合并来派生行,因此列表未携带的成员没有行也没有取消归档操作,尽管归档集合仍持有它;当集合中的成员全部处于该状态时,页面报告无法恢复,而不是报告归档为空。 -- **页面只列出会话,不提供会话删除**:归档可通过本页面恢复,而删除会话记录仍是彼此独立的能力。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -无。 - -
- -**运行时不变式:** 不发布伴生入口。本包是浏览器端设置页,只注册一个本地化 `settings.section` 贡献及其 locale namespace;它不发出 Cordis 事件,也不持有跨插件可变关系。 diff --git a/packages/client/ui-settings-unarchive-sessions/package.json b/packages/client/ui-settings-unarchive-sessions/package.json deleted file mode 100644 index d9b13ccde6..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/package.json +++ /dev/null @@ -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" - ] -} diff --git a/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css b/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css deleted file mode 100644 index 7e2f55c85a..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css +++ /dev/null @@ -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); -} diff --git a/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx b/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx deleted file mode 100644 index cab7df1c7b..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx +++ /dev/null @@ -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 -} - -/** Full component props assembled by the Settings slot renderer. */ -export type ArchivedSessionsSectionProps = - PropsRuntime<'settings.section'> - & PropsLocale<'settings.archivedSessions'> - & InjectFace - -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(() => { - const owners = new Map() - 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

{t('loading')}

- - const now = Date.now() - const visible = rows.filter(row => matches(row, query.trim().toLowerCase())) - const archived = archivedSessionIds.length > 0 - - return ( -
-
-
- {!archived ?

{t('empty')}

: null} - {archived && rows.length === 0 ?

{t('unavailable')}

: null} - {rows.length > 0 && visible.length === 0 ?

{t('emptySearch')}

: null} - {visible.length > 0 ? ( -
    - {visible.map(row => ( -
  • - - {row.title} - - {[row.workspace, timeLabel(row.updatedAt, now, t)].join(' · ')} - - - -
  • - ))} -
- ) : null} -
- ) -} diff --git a/packages/client/ui-settings-unarchive-sessions/src/client/index.ts b/packages/client/ui-settings-unarchive-sessions/src/client/index.ts deleted file mode 100644 index 353e6c072b..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/client/index.ts +++ /dev/null @@ -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)) -} diff --git a/packages/client/ui-settings-unarchive-sessions/src/client/locales.ts b/packages/client/ui-settings-unarchive-sessions/src/client/locales.ts deleted file mode 100644 index 4b408687ef..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/client/locales.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** Copy dictionaries for the archived-session Settings page. */ - -/** Simplified Chinese dictionary and key source of truth. */ -export const zh = { - nav: '已归档会话', - search: '搜索已归档会话', - loading: '正在读取会话…', - empty: '暂无已归档会话。', - unavailable: '这里没有可恢复的已归档会话。', - emptySearch: '没有匹配的会话。', - unarchive: '取消归档', - unarchiveNamed: '取消归档 {title}', - ungrouped: '未分组', - 'time.now': '刚刚', - 'time.minutes': '{n}分钟', - 'time.hours': '{n}小时', - 'time.days': '{n}天', - 'time.months': '{n}个月', - 'time.years': '{n}年', -} satisfies Record - -/** Archived-session page locale key union. */ -export type ArchivedSessionsLocaleKey = keyof typeof zh - -/** English dictionary checked against the Chinese key set. */ -export const en = { - nav: 'Archived sessions', - search: 'Search archived sessions', - loading: 'Reading sessions…', - empty: 'No archived sessions.', - unavailable: 'No archived session here can be restored.', - emptySearch: 'No matching sessions.', - unarchive: 'Unarchive', - unarchiveNamed: 'Unarchive {title}', - ungrouped: 'Ungrouped', - 'time.now': 'now', - 'time.minutes': '{n}min', - 'time.hours': '{n}h', - 'time.days': '{n}d', - 'time.months': '{n}mo', - 'time.years': '{n}y', -} satisfies Record diff --git a/packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts b/packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts deleted file mode 100644 index bc5e482353..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module '*.module.css' { - const classes: Record - export default classes -} - -declare module '*.css' diff --git a/packages/client/ui-settings-unarchive-sessions/src/index.ts b/packages/client/ui-settings-unarchive-sessions/src/index.ts deleted file mode 100644 index 3ebf995f31..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Host loader entry for the archived-session page exported from `./client`. */ - -/** Host plugin body — the archived-session page is browser-only. */ -export function apply(): void {} diff --git a/packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx deleted file mode 100644 index 48d8634251..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// @vitest-environment jsdom -import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup } from '@testing-library/react' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { apply, inject } from '../src/client/index.ts' -import { ArchivedSessionsSection } from '../src/client/ArchivedSessionsSection.tsx' -import type { ArchivedSessionsSectionInjected } from '../src/client/ArchivedSessionsSection.tsx' -import { apply as hostApply } from '../src/index.ts' - -usePinnedBrowserLanguages('zh-CN') -afterEach(cleanup) - -async function bench() { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - const locale = new LocaleRuntime(ctx) - ctx.provide('locale', locale) - const unarchiveSession = vi.fn<(sessionId: SessionId) => Promise>(async () => {}) - ctx.provide('uiWorkspace', { unarchiveSession }) - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, unarchiveSession } -} - -function declare(slots: SlotRegistry): () => void { - return slots.register({ - name: 'root', - children: { 'settings.section': { kind: 'list', scope: 'root' } }, - } as never, () => null) -} - -describe('ui-settings-unarchive-sessions browser plugin', () => { - it('keeps the host Loader entry inert', () => { - expect(hostApply).not.toThrow() - }) - - it('declares only the services used by the page and the archive write', () => { - expect(inject).toEqual(['slots', 'locale', 'uiWorkspace']) - }) - - it('registers the archived-session page last with localized copy', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - - const entry = b.slots.entries('settings.section')[0]! - expect(entry.component).toBe(ArchivedSessionsSection) - expect(entry.options).toMatchObject({ id: 'archived-sessions', order: 25 }) - expect(entry.locale).toBe('settings.archivedSessions') - expect(resolveSlotLabel(entry.options.label)).toBe('已归档会话') - - b.locale.setLocale('en') - expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Archived sessions') - - const injected = (entry.inject as unknown as () => ArchivedSessionsSectionInjected)() - await expect(injected.unarchive('session-one' as SessionId)).resolves.toBeUndefined() - expect(b.unarchiveSession).toHaveBeenCalledWith('session-one') - await b.ctx.fiber.dispose() - }) - - it('follows a late declaration and a declarer reload', async () => { - const b = await bench() - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.entries('settings.section')).toHaveLength(0) - - const stop = declare(b.slots) - await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) }) - - stop() - expect(b.slots.entries('settings.section')).toHaveLength(0) - declare(b.slots) - await vi.waitFor(() => { - expect(b.slots.entries('settings.section')[0]?.component).toBe(ArchivedSessionsSection) - }) - - await fiber.dispose() - expect(b.slots.entries('settings.section')).toHaveLength(0) - expect(() => b.locale.register('settings.archivedSessions', 'zh', {})).not.toThrow() - await b.ctx.fiber.dispose() - }) -}) diff --git a/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx b/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx deleted file mode 100644 index a944d8dea9..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx +++ /dev/null @@ -1,158 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' -import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { ArchivedSessionsSection } from '../src/client/ArchivedSessionsSection.tsx' -import type { ArchivedSessionsSectionProps } from '../src/client/ArchivedSessionsSection.tsx' -import { en, type ArchivedSessionsLocaleKey } from '../src/client/locales.ts' - -afterEach(cleanup) - -const DAY = 86_400_000 -const sid = (id: string): SessionId => id as SessionId - -const t = ((key: ArchivedSessionsLocaleKey, params?: Record): string => - Object.entries(params ?? {}).reduce( - (text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), - en[key], - )) as ArchivedSessionsSectionProps['t'] - -function summary(id: string, title: string, updatedAt: number): SessionSummary { - return { id: sid(id), displayTitle: title, running: false, retainedBy: {}, blank: false, updatedAt } -} - -function sessionState(sessions: readonly SessionSummary[], phase: SessionListState['phase'] = 'ready'): SessionListState { - return { - ids: sessions.map(session => session.id), - byId: Object.fromEntries(sessions.map(session => [session.id, session])), - phase, - subagentsByParent: {}, - jobsBySession: {}, - } -} - -function snapshot(archivedSessionIds: readonly string[], items: readonly WorkspaceView[] = []): WorkspaceSnapshot { - return { items, archivedSessionIds: archivedSessionIds.map(sid), state: 'idle', phase: 'ready', error: null } -} - -function workspace(title: string, sessionIds: readonly string[]): WorkspaceView { - return { - workspaceId: title as WorkspaceView['workspaceId'], - path: `/work/${title}`, - title, - sessionIds: sessionIds.map(sid), - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - } -} - -function props(options: { - sessions: SessionListState - workspaces: WorkspaceSnapshot - unarchive?: (sessionId: SessionId) => Promise -}): ArchivedSessionsSectionProps { - return { - t, - unarchive: options.unarchive ?? (async () => {}), - useSessions: ((select: (state: SessionListState) => unknown) => select(options.sessions)), - useWorkspaces: ((select: (state: WorkspaceSnapshot) => unknown) => select(options.workspaces)), - } as unknown as ArchivedSessionsSectionProps -} - -/** Two archived sessions in one Workspace, archived oldest first. */ -function twoRows(): ArchivedSessionsSectionProps { - return props({ - sessions: sessionState([ - summary('older', 'Older session', Date.now() - 2 * DAY), - summary('newer', 'Newer session', Date.now()), - ]), - workspaces: snapshot(['older', 'newer'], [workspace('Project', ['older', 'newer'])]), - }) -} - -describe('ArchivedSessionsSection', () => { - it('lists the newest archive first with its Workspace and last activity', () => { - render() - - expect(screen.getAllByRole('listitem').map(row => row.textContent)).toEqual([ - 'Newer sessionProject · nowUnarchive', - 'Older sessionProject · 2dUnarchive', - ]) - expect(screen.getAllByRole('button', { name: /^Unarchive/ }).map(button => button.getAttribute('aria-label'))) - .toEqual(['Unarchive Newer session', 'Unarchive Older session']) - }) - - it('labels a session outside every Workspace and hides an entry whose session is gone', () => { - render() - - expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('Loose session')).toBeTruthy() - expect(screen.getByText('Ungrouped · now')).toBeTruthy() - }) - - it('reads sessions until the list arrives, then reports an empty archive', () => { - const pending = render() - expect(screen.getByText(en.loading)).toBeTruthy() - expect(screen.queryByRole('searchbox')).toBeNull() - pending.unmount() - - render() - expect(screen.getByText(en.empty)).toBeTruthy() - expect(screen.queryByRole('listitem')).toBeNull() - }) - - it('does not report an empty archive while every entry lacks a Session summary', () => { - render() - - expect(screen.getByText(en.unavailable)).toBeTruthy() - expect(screen.queryByText(en.empty)).toBeNull() - expect(screen.queryByText(en.emptySearch)).toBeNull() - }) - - it('filters by title and Workspace and reports a query with no match', () => { - render() - const search = screen.getByRole('searchbox', { name: en.search }) - - fireEvent.change(search, { target: { value: 'older' } }) - expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('Older session')).toBeTruthy() - - fireEvent.change(search, { target: { value: 'project' } }) - expect(screen.getAllByRole('listitem')).toHaveLength(2) - - fireEvent.change(search, { target: { value: 'absent' } }) - expect(screen.getByText(en.emptySearch)).toBeTruthy() - expect(screen.queryByRole('listitem')).toBeNull() - expect(screen.queryByText(en.empty)).toBeNull() - }) - - it('unarchives the clicked row and keeps a rejection as a console diagnostic', async () => { - const unarchive = vi.fn(async () => {}) - render() - fireEvent.click(screen.getByRole('button', { name: 'Unarchive Newer session' })) - expect(unarchive).toHaveBeenCalledWith('newer') - - cleanup() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const failure = new Error('transport down') - render( { throw failure }, - })} />) - fireEvent.click(screen.getByRole('button', { name: 'Unarchive Older session' })) - await waitFor(() => { expect(warn).toHaveBeenCalledWith('session unarchive rejected:', failure) }) - warn.mockRestore() - }) -}) diff --git a/packages/client/ui-settings-unarchive-sessions/tsconfig.json b/packages/client/ui-settings-unarchive-sessions/tsconfig.json deleted file mode 100644 index 35af3c7e53..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/tsconfig.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../locale" - }, - { - "path": "../ui-primitives" - }, - { - "path": "../ui-renderer" - }, - { - "path": "../ui-session" - }, - { - "path": "../ui-settings" - }, - { - "path": "../ui-slots" - }, - { - "path": "../ui-workspace" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/packages/client/ui-settings-unarchive-sessions/tsdown.config.ts b/packages/client/ui-settings-unarchive-sessions/tsdown.config.ts deleted file mode 100644 index 2fcc0276fc..0000000000 --- a/packages/client/ui-settings-unarchive-sessions/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-unarchive-sessions', ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx index e4af1e420d..58ab8104a4 100644 --- a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx +++ b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx @@ -1,6 +1,6 @@ /** macOS-desktop resident-header controls for the fully hidden sidebar. */ import { - IconNewChatOutlineMedium, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, + IconNewChatOutlineRegular, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the conversation header slot declarations. @@ -45,7 +45,7 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, t }: Header aria-label={t('session.new.label')} onClick={() => { startSession() }} > - +
diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index ea362a51f2..874e81ff3a 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -19,7 +19,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - FishLogo, IconNewChatOutlineMedium, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, + FishLogo, IconNewChatOutlineMedium, IconNewChatOutlineRegular, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { @@ -245,7 +245,11 @@ export function SidebarRoot({ aria-label={t('session.new.label')} onClick={() => { startSession() }} > - + {/* The rail draws Regular: Medium's 1.3px stroke scaled to the rail's + larger glyph reads visibly heavier than the neighboring 1px icons. */} + {wide + ? + : } {wide && {t('session.new')}} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index ce6cdec05b..d3f6bbfc56 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -44,7 +44,7 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded data-content="47b5e433" fill="none" height="16" - stroke-width="1.3" + stroke-width="1" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg" @@ -278,7 +278,7 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad data-content="47b5e433" fill="none" height="18" - stroke-width="1.3" + stroke-width="1" viewBox="0 0 16 16" width="18" xmlns="http://www.w3.org/2000/svg" diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 9ff8e82684..4cc18f3be5 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -22,7 +22,8 @@ body { --dsw-static-deepseek-100: rgb(228, 237, 253); --dsw-static-deepseek-200: rgb(211, 226, 255); --dsw-static-deepseek-300: rgb(183, 200, 254); - --dsw-static-deepseek-400: rgb(103, 158, 254); + /* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */ + --dsw-static-deepseek-400: rgb(122, 170, 255); --dsw-static-deepseek-450: rgb(86, 134, 254); --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); @@ -98,7 +99,8 @@ body[data-ds-dark-theme] { --dsw-static-deepseek-100: rgb(228, 237, 253); --dsw-static-deepseek-200: rgb(211, 226, 255); --dsw-static-deepseek-300: rgb(183, 200, 254); - --dsw-static-deepseek-400: rgb(103, 158, 254); + /* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */ + --dsw-static-deepseek-400: rgb(122, 170, 255); --dsw-static-deepseek-450: rgb(86, 134, 254); --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index 60a830a76b..35ee863fd4 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -31,7 +31,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 7ec72aa242..23a049ce13 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -33,7 +33,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index a7e0830c66..6ac678d4ac 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -178,7 +178,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } @@ -835,6 +835,9 @@ describe('timeline projection', () => { expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy() fireEvent.pointerEnter(boundary) expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull() + // Keyboard modality first: Tooltip suppresses focus arriving after a + // pointer interaction, and earlier cases in this file press pointers. + fireEvent.keyDown(boundary, { key: 'Tab' }) fireEvent.focus(boundary) expect(screen.getByRole('tooltip').textContent) .toContain('Click to load earlier history') diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 29770236b8..3e8c54f972 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -59,6 +59,7 @@ const attentionState: AttentionState = new Map() const workspaceState = { items: [], archivedSessionIds: [], + pinnedSessionIds: [], state: 'idle' as const, phase: 'ready' as const, error: null, diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index b83eb16262..464b664eea 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -57,6 +57,7 @@ const attentionState: AttentionState = new Map() const workspaceState = { items: [], archivedSessionIds: [], + pinnedSessionIds: [], state: 'idle' as const, phase: 'ready' as const, error: null, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 5843a2009f..b39d737103 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: eabd5dc9eb8cad987da5eb7b6a747c3650ce69a4 -README.zh.md: 4358a00617376259a85b0e995aff3f95b295a2ff +README.md: 2f5ccee44e9d69eec45cf23bb8c368d67974eda7 +README.zh.md: 173129d6d03ccd6d3ccf9a4f72effae612711bcb diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index eabd5dc9eb..2f5ccee44e 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -29,7 +29,11 @@ Use the sidebar to browse Workspaces and their Sessions, reorder them, and start ### Reordering and view options -**Last updated** orders ordinary Sessions by their latest user prompt or steer time, newest first, in both grouped and flat views. **Manual** freezes the current displayed order and holds positions when activity changes; newly discovered ordinary Sessions append to the end, newest first when several arrive together. Returning to Last updated discards every manual position, and entering Manual again freezes the then-current recency order. The browser defaults to Last updated and remembers the selected mode across reloads. Dragging an ordinary Session applies the move locally and selects Manual. The selected blank **New Session** is always pinned first and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining its first position in Manual or following its current timestamp in Last updated. In a collapsed group, drag boundaries follow rendered rows and place the source before intervening hidden rows, so a drag cannot hide its source. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable. +Pinned Sessions lead ordinary Sessions in both grouped and flat views. **Last updated** sorts each partition strictly by the latest user prompt or steer time, newest first; pin time does not affect it. **Manual** uses the relative positions in one complete Session sequence, including hidden archives. Returning to Last updated discards the manual layout, and entering Manual again freezes the then-current chronological order. The browser defaults to Last updated and remembers the selected mode across reloads. + +Pinning moves the Session to the front of its complete saved sequence without changing the selected mode. It leads the pinned partition in Manual but need not lead it in Last updated. Unpinning leaves the saved position unchanged. Dragging either a pinned or ordinary row edits that same complete sequence and selects Manual; hidden archived members are retained. Missing pinned members prepend in pin-array order, while other missing members append by recency with missing archives last. Supplementation stays in memory until a Session-order write records the complete result. Archive filtering alone changes neither saved positions nor account membership. + +The selected blank **New Session** retains its provisional first slot and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining that position in Manual or following its current timestamp in Last updated. A collapsed-group drag uses the target Session identity and keeps the source visible. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable. The [Session pinning and archive decision](../../../.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md) records the ordering and recovery rules. ### Workspace hierarchy @@ -39,11 +43,13 @@ Hierarchy uses registered canonical paths only. It does not scan for projects or ### Search -Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20. Choosing a result clears and collapses search, opens the Session, and scrolls its row into view in the configured browsing mode; grouped browsing also expands its Workspace and the full Session list when required. +Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible without an additional warning. The list is capped at 20. Choosing an unarchived result clears and collapses search, opens the Session, and scrolls its row into view in the configured browsing mode; grouped browsing also expands its Workspace and the full Session list when required. Archived results offer Unarchive; attempting to open one explains that restriction without clearing the query or navigating. ### Managing sessions -The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Rename uses a temporary `workspaceOperation` reference, while fork-title assignment uses a temporary `controllerOperation` reference inside Session Controller; both await the reference's initial history opening. Archive commits without a confirmation dialog and the row disappears from every grouping surface when the archive-set echo lands. Fork forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. +The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference, while fork-title assignment uses a temporary `controllerOperation` reference inside Session Controller; both await the reference's initial history opening. Fork forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. + +Archive commits without a confirmation dialog and retains the Session's account position. View options control visibility: the default hides archived Sessions, Show archived includes them, and Archived only hides ordinary Sessions. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a toast with undo and filter-menu actions. Unarchive removes the archive mark without restoring a pin or changing the saved position. A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start. @@ -77,7 +83,11 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv ### View state -Once the Workspace list baseline is ready, browser-persisted expansion and manual Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions require the current account orders explicitly. Flat-list membership and ordering use Session ids; row rendering adds status indicators once. Entering Manual snapshots every active account from the current display; reconciliation retains saved members that still belong to the account, removes departed members, and appends newly known members by recency. A new membership entry without a Session summary is omitted until that summary arrives, while an already saved slot survives a temporarily missing summary. During Workspace reconnection, Manual records an observed blank Session at the front of its saved flat and known group orders without removing other saved members; full membership reconciliation waits for the Workspace baseline. This reconciliation remains mounted while the sidebar is a rail or search replaces its body. Last updated derives directly from each current list snapshot without reading or writing saved positions; equal timestamps use Session ids as a stable tie-break. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`, and each visible ordinary row inherits the shared ongoing loader while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`. +Once the Workspace baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions receive complete account orders, never filtered rows. A new member without a Session summary waits for that summary, while a saved position survives a temporarily missing summary. Archive visibility is applied only when deriving rows. Pin and drag writes save complete orders; ordinary derivation does not write them. The selected blank Session remains an explicit position write, including during Workspace reconnection, when other saved members are retained until the baseline establishes membership. Ordering remains mounted while the sidebar is a rail or search replaces its body. Last updated derives from current summaries without reading saved positions; equal timestamps use Session ids as a stable tie-break. + +The sidebar hides durable summaries with `origin: 'subagent'`. Each visible ordinary row inherits the shared ongoing loader while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`. + +Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measures keyed rows before and after React commits that change their membership or order, and uses native movement and opacity animations. Removed rows fade as inert copies outside the scrolling list; they do not delay React unmounting or extend its scroll range. Initial loading, drag commits, overflow expansion, and view-option changes settle immediately. The animator has no layout observers or polling and does not measure content-only updates or scrolling. ### Hover cards @@ -116,7 +126,7 @@ None; this package neither assembles nor sends a provider request. These limits define the search depth, the archive surface, and the picking carrier; they are current package constraints. - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. -- **No Session deletion, and unarchive lives in Settings** — sessions can be archived but never deleted; the archived-sessions Settings page ([ui-settings-unarchive-sessions](../ui-settings-unarchive-sessions/README.md)) owns viewing and restoring them, and Workspace registration deletion does not delete Sessions. +- **No Session deletion** — sessions can be archived but never deleted; archived rows stay recoverable in place through the archived view filter and the search results' unarchive action, and Workspace registration deletion does not delete Sessions. - **Pending user interaction is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, in-process or remote browser deployments cannot open a local operating-system dialog; remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 4358a00617..173129d6d0 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -29,7 +29,11 @@ kind: "package-reference" ### 重排序与视图选项 -**最近更新**在分组和单列表视图中都按普通 Session 的最近一次用户提示词或 steering(中途引导)时间降序排列。**手动排序**会冻结当前显示顺序,并在活动变化时保持已有位置;新发现的普通 Session 追加到末尾,多条同时到达时按最近更新时间降序排列。返回最近更新会丢弃所有手动位置;再次进入手动排序时,则冻结当时的最近更新顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。拖拽普通 Session 会在浏览器本地应用移动并选中手动排序。当前选中的空白**新会话**始终固定在首位且无法拖拽;首条提示词落地后,它成为可拖拽的普通行,在手动排序中保留首位,在最近更新中按当前时间戳排列。折叠分组的拖拽边界按渲染行确定,并把来源行放在中间隐藏行之前,因此拖拽不会隐藏来源行。真实 Workspace、Ungrouped 与单列表的 Session 显示顺序都保留在浏览器本地;Workspace 分组的拖拽顺序仍由 Host 持久化。 +分组和平铺视图都先显示置顶 Session,再显示普通 Session。**最近更新**在各分区内严格按最近一次用户提示词或 steering(中途引导)时间降序排列,置顶时间不影响它。**手动排序**使用同一条完整 Session 序列中的相对位置,包括隐藏的归档项。返回最近更新会丢弃手动布局,再次进入手动排序时冻结当时的时间顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。 + +置顶把 Session 移到完整保存序列的首位,但不切换所选模式。它在手动模式下排在置顶区最前,在最近更新模式下则不一定。取消置顶不改变保存的位置。拖拽置顶行或普通行都修改同一条完整序列并选择手动模式,隐藏的归档成员始终保留。缺失的置顶成员按置顶数组顺序补到头部,其他缺失成员按最近更新时间追加,缺失归档项放在最后。补齐仅在内存中完成,直到一次 Session 顺序写入保存完整结果。单纯切换归档筛选既不改变保存的位置,也不改变成员关系。 + +当前选中的空白**新会话**保留临时首位且无法拖拽;首条提示词落地后,它成为可拖拽的普通行,在手动排序中保留该位置,在最近更新中按当前时间戳排列。折叠分组的拖拽使用目标 Session 身份,并保持来源行可见。真实 Workspace、Ungrouped 与单列表的 Session 显示顺序都保留在浏览器本地;Workspace 分组的拖拽顺序仍由 Host 持久化。[会话置顶与归档决定](../../../.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md)记录排序与恢复规则。 ### 工作区层级 @@ -39,11 +43,13 @@ kind: "package-reference" ### 搜索 -折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果。选择结果会清空并收起搜索、打开 Session,并在当前浏览模式中将其行滚动到可见区域;分组浏览还会按需展开所属 Workspace 和完整 Session 列表。 +折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,不另给警告。列表最多显示 20 条结果。选择未归档结果会清空并收起搜索、打开 Session,并在当前浏览模式中将其行滚动到可见区域;分组浏览还会按需展开所属 Workspace 和完整 Session 列表。已归档结果提供取消归档操作;尝试打开时会说明限制,不清空查询,也不导航。 ### 管理会话 -Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Rename 使用临时 `workspaceOperation` reference,fork 标题设置则由 Session Controller 使用临时 `controllerOperation` reference;两者都等待该 reference 的首次历史打开。Archive 不经确认对话框直接提交,归档集合回声落地后,该行从所有分组视图中消失。Fork 在源会话最后一个已完成轮次处 fork,在客户端递增继承的持久化标题后再打开子会话。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。 +Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,fork 标题设置则由 Session Controller 使用临时 `controllerOperation` reference;两者都等待该 reference 的首次历史打开。Fork 在源会话最后一个已完成轮次处 fork,在客户端递增继承的持久化标题后再打开子会话。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。 + +Archive 不经确认对话框直接提交,并保留 Session 的记账位置。视图选项控制显隐:默认隐藏已归档 Session,显示已归档会将其纳入列表,仅显示已归档则隐藏普通 Session。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后,toast 提供撤销与打开筛选菜单的操作。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。 标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。 @@ -77,7 +83,11 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** ### 视图状态 -Workspace 列表基线就绪后,浏览器持久化的展开状态和手动 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作要求显式传入当前各记账的顺序。单列表的成员筛选和排序使用 Session id,行渲染只计算一次状态指示。进入手动排序会从当前显示结果一次性记录每个有效记账;对账会保留仍属于该记账的已保存成员、移除已经离开的成员,并按最近更新时间追加新发现的成员。尚无 Session 摘要的新成员会等摘要到达后再加入,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 重连期间,手动排序会将已观察到的空白 Session 记录到已保存的单列表及已知分组顺序首位,不移除其他已保存成员;完整成员对账等待 Workspace 基线到齐。即使侧边栏收成窄栏或搜索替代列表主体,这项对账也保持挂载。最近更新直接从每份当前列表快照派生,不读取或写入已保存位置;时间相同时按 Session id 稳定排序。共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承共享 ongoing loading。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。 +Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作接收完整记账顺序,而不是筛选后的行。尚无 Session 摘要的新成员会等待摘要,已保存的位置则在摘要暂时缺失时保留。归档显隐仅在派生行时应用。置顶和拖拽写入完整顺序,普通派生不执行写入。当前选中的空白 Session 仍是一次显式位置写入;Workspace 重连时同样如此,此时保留其他已保存成员,直到基线确定成员关系。侧边栏收成窄栏或搜索替代列表主体时,排序仍保持挂载。最近更新从当前摘要派生,不读取已保存位置;时间相同时按 Session id 稳定排序。 + +侧边栏隐藏持久化摘要中带有 `origin: 'subagent'` 的行。每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承共享 ongoing loading。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。 + +行动画由 [AnimatedRows](src/client/rows/AnimatedRows.tsx) 负责。它仅在 React 提交改变行成员或顺序时读取更新前后的位置,并使用浏览器原生位移与透明度动画。被移除的行以不可交互的副本在滚动列表外淡出,不会延迟 React 卸载,也不会扩大列表的滚动范围。初始加载、拖拽提交、展开其余会话和视图选项变化直接完成。动画组件不使用布局观察器或轮询,也不会因仅内容更新或滚动而测量位置。 ### 悬浮卡片 @@ -116,7 +126,7 @@ Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Wor 这些限制定义搜索深度、归档界面与选取载体;它们是当前包约束。 - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 -- **没有 Session 删除,取消归档位于设置中**:会话可以归档但绝不会被删除;已归档会话的查看与恢复由「已归档会话」设置页([ui-settings-unarchive-sessions](../ui-settings-unarchive-sessions/README.zh.md))负责,删除 Workspace 注册记录不会删除 Session。 +- **没有 Session 删除**:会话可以归档但绝不会被删除;已归档的行通过「已归档会话」视图筛选与搜索结果中的取消归档操作原位恢复,删除 Workspace 注册记录不会删除 Session。 - **待处理的用户交互不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,进程内部署或远程浏览器部署无法打开本地操作系统对话框;可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 0686e58497..fe7a7c8a19 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", @@ -66,14 +67,14 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-schedule": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index baa263b1a6..f8d50e58d5 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -134,6 +134,12 @@ export type WorkspaceBrowserInjected = { * session clears the selection into the New Session view state. */ archiveSession: (sessionId: SessionId) => Promise + /** Remove a Session from the registry-global archived set. */ + unarchiveSession: (sessionId: SessionId) => Promise + /** Add a Session to the registry-global pinned set (newest pin first). */ + pinSession: (sessionId: SessionId) => Promise + /** Remove a Session from the registry-global pinned set. */ + unpinSession: (sessionId: SessionId) => Promise /** Adopt a picked host directory as a real Workspace before targeting a Session. */ createWorkspace: (input: { path: string }) => Promise } diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 514ddd9e92..44d65efb20 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -132,6 +132,9 @@ export function apply(ctx: Context): void { await workspaces.insertBefore(workspaceId, beforeWorkspaceId) }, archiveSession: async (sessionId) => { await uiWorkspace.archiveSession(sessionId) }, + unarchiveSession: async (sessionId) => { await uiWorkspace.unarchiveSession(sessionId) }, + pinSession: async (sessionId) => { await workspaces.pinSession(sessionId) }, + unpinSession: async (sessionId) => { await workspaces.unpinSession(sessionId) }, createWorkspace: input => workspaces.create(input), hooks: { directoryFlow: browserFlowSource, hostInfo }, }) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 95c4d184eb..e721af231f 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -18,17 +18,19 @@ export const zh = { 'orderBy.label': '排序方式', 'orderBy.manual': '手动排序', 'orderBy.updated': '最近更新', + 'filterBy.label': '筛选会话', + 'viewOptions.showArchived': '显示已归档', + 'viewOptions.onlyArchived': '仅显示已归档', 'sessions.expand': '展开其余 {n} 个会话', 'sessions.collapse': '收起', 'empty.none': '暂无会话', 'empty.noMatches': '无匹配结果', 'workspace.add': '添加工作区', 'search.sessions.aria': '搜索会话', - 'search.placeholder': '搜索会话…', + 'search.placeholder': '搜索会话名称', 'search.clear': '清除搜索', 'search.results.aria': '搜索结果', 'search.pending': '正在搜索会话历史…', - 'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。', 'search.noMatches': '无匹配会话', 'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。', 'menu.addWorkspace': '添加工作区…', @@ -46,10 +48,27 @@ export const zh = { 'delete.pending': '正在删除工作区…', 'menu.fork': '分叉会话', 'menu.archiveSession': '归档会话', + 'menu.unarchiveSession': '取消归档', + 'menu.pinSession': '置顶会话', + 'menu.unpinSession': '取消置顶', + 'row.archived': '已归档', + 'row.pinned': '已置顶', + 'toast.archivedNotOpenable': '已归档对话暂时无法查看,请取消归档后查看', + 'toast.archived': '会话已归档,可', + 'toast.archivedUndo': '撤销', + 'toast.archivedOr': '或', + 'toast.archivedFilter': '筛选已归档会话', + 'toast.pinFailed': '置顶失败,请稍后重试', + 'toast.unpinFailed': '取消置顶失败,请稍后重试', 'sessions.count.one': '{n} 个会话', 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', 'actions.session.aria': '会话“{name}”的操作', + 'actions.archive': '归档会话', + 'actions.unarchive': '取消归档', + 'actions.pin': '置顶会话', + 'actions.unpin': '取消置顶', + 'actions.newSession': '新会话', 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', 'status.subagentsRunning.one': '{n} 个子代理运行中', @@ -92,17 +111,19 @@ export const en = { 'orderBy.label': 'Order by', 'orderBy.manual': 'Manual', 'orderBy.updated': 'Last updated', + 'filterBy.label': 'Filter sessions', + 'viewOptions.showArchived': 'Show archived', + 'viewOptions.onlyArchived': 'Archived only', 'sessions.expand': 'Show {n} more sessions', 'sessions.collapse': 'Show less', 'empty.none': 'No sessions yet', 'empty.noMatches': 'No matches', 'workspace.add': 'Add workspace', 'search.sessions.aria': 'Search sessions', - 'search.placeholder': 'Search sessions...', + 'search.placeholder': 'Search session names', 'search.clear': 'Clear search', 'search.results.aria': 'Search results', 'search.pending': 'Searching session history…', - 'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.', 'search.noMatches': 'No matching sessions', 'search.hasMore': 'Showing the first {n} results. Narrow your search.', 'menu.addWorkspace': 'Add workspace…', @@ -120,10 +141,27 @@ export const en = { 'delete.pending': 'Deleting workspace…', 'menu.fork': 'Fork session', 'menu.archiveSession': 'Archive session', + 'menu.unarchiveSession': 'Unarchive session', + 'menu.pinSession': 'Pin session', + 'menu.unpinSession': 'Unpin session', + 'row.archived': 'Archived', + 'row.pinned': 'Pinned', + 'toast.archivedNotOpenable': 'Archived sessions cannot be opened. Unarchive it to view.', + 'toast.archived': 'Session archived. You can ', + 'toast.archivedUndo': 'undo', + 'toast.archivedOr': ' or ', + 'toast.archivedFilter': 'filter archived sessions', + 'toast.pinFailed': 'Pin failed. Try again later.', + 'toast.unpinFailed': 'Unpin failed. Try again later.', 'sessions.count.one': '{n} session', 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', 'actions.session.aria': 'Session actions for {name}', + 'actions.archive': 'Archive', + 'actions.unarchive': 'Unarchive', + 'actions.pin': 'Pin', + 'actions.unpin': 'Unpin', + 'actions.newSession': 'New session', 'actions.newSession.aria': 'New session in {name}', 'status.running': 'Running', 'status.subagentsRunning.one': '{n} subagent running', diff --git a/packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css b/packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css new file mode 100644 index 0000000000..6d6c538f44 --- /dev/null +++ b/packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css @@ -0,0 +1,7 @@ +.exits { + position: absolute; + inset: 0; + contain: strict; + overflow: clip; + pointer-events: none; +} diff --git a/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx b/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx new file mode 100644 index 0000000000..12030ab6f2 --- /dev/null +++ b/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx @@ -0,0 +1,183 @@ +/** React-commit-driven movement and entry/exit fades for the sidebar's keyed rows. */ +import { Component, createRef, type ReactNode } from 'react' +import css from './AnimatedRows.module.css' + +const ROW_FADE_MS = 100 +const ROW_GLIDE_MS = 200 + +interface AnimatedRowsProps { + children: ReactNode + className: string + label: string + /** Unique DOM-order keys matching the rendered data-row-key attributes. */ + rowKeys: readonly string[] + ready: boolean + /** Changes that replace the view or reveal hidden rows settle immediately. */ + resetKey: string +} + +interface RowPosition { + element: HTMLElement + rect: DOMRect + opacity: number +} + +interface RowSnapshot { + positions: Map + removed: Map +} + +function sameRows(previous: AnimatedRowsProps, next: AnimatedRowsProps): boolean { + return previous.rowKeys.length === next.rowKeys.length + && previous.rowKeys.every((key, index) => key === next.rowKeys[index]) +} + +function intersects(row: DOMRect, viewport: DOMRect): boolean { + return row.bottom > viewport.top && row.top < viewport.bottom + && row.right > viewport.left && row.left < viewport.right +} + +/** + * Animates keyed sidebar rows only when their rendered membership or order changes. + * Motion starts after the first pointer or keyboard input inside the mounted list. + * The parent supplies a positioned container for the inert exit overlay. + */ +export class AnimatedRows extends Component { + private armed = false + private readonly list = createRef() + private readonly overlay = createRef() + private readonly movements = new Map() + private readonly exits = new Map() + + override getSnapshotBeforeUpdate(previous: AnimatedRowsProps): RowSnapshot | null { + const list = this.list.current + if (!this.armed || sameRows(previous, this.props) || previous.resetKey !== this.props.resetKey + || !previous.ready || !this.props.ready || list === null + || typeof list.animate !== 'function' + || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return null + + const viewport = list.getBoundingClientRect() + const positions = this.readPositions() + const nextKeys = new Set(this.props.rowKeys) + const removed = new Map() + for (const [key, row] of positions) { + if (nextKeys.has(key) || !intersects(row.rect, viewport)) continue + const clone = row.element.cloneNode(true) as HTMLElement + clone.removeAttribute('data-row-key') + clone.inert = true + clone.style.setProperty( + '--dsh-workspace-indent', getComputedStyle(row.element).getPropertyValue('--dsh-workspace-indent'), + ) + removed.set(key, { ...row, element: clone }) + } + return { positions, removed } + } + + override componentDidUpdate(previous: AnimatedRowsProps, _state: unknown, snapshot: RowSnapshot | null): void { + if (snapshot === null) { + if (!sameRows(previous, this.props) || previous.resetKey !== this.props.resetKey + || previous.ready !== this.props.ready) this.clear() + return + } + + this.cancelMovements() + const list = this.list.current as HTMLDivElement + const overlay = this.overlay.current as HTMLDivElement + const viewport = list.getBoundingClientRect() + const origin = overlay.getBoundingClientRect() + const positions = this.readPositions() + + for (const [key, row] of positions) { + this.removeExit(key) + const previousRow = snapshot.positions.get(key) + if (!intersects(row.rect, viewport) + && (previousRow === undefined || !intersects(previousRow.rect, viewport))) continue + if (previousRow === undefined) { + this.move(row.element, [{ opacity: 0 }, { opacity: 1 }], ROW_FADE_MS) + continue + } + const dx = previousRow.rect.left - row.rect.left + const dy = previousRow.rect.top - row.rect.top + if (dx === 0 && dy === 0 && previousRow.opacity === 1) continue + this.move(row.element, [ + { transform: `translate(${String(dx)}px, ${String(dy)}px)`, opacity: previousRow.opacity }, + { transform: 'translate(0, 0)', opacity: 1 }, + ], ROW_GLIDE_MS) + } + + for (const [key, row] of snapshot.removed) { + const { element } = row + this.removeExit(key) + Object.assign(element.style, { + position: 'absolute', margin: '0', transform: 'none', boxSizing: 'border-box', + left: `${String(row.rect.left - origin.left)}px`, + top: `${String(row.rect.top - origin.top)}px`, + width: `${String(row.rect.width)}px`, height: `${String(row.rect.height)}px`, + }) + overlay.append(element) + const animation = element.animate([{ opacity: row.opacity }, { opacity: 0 }], { + duration: ROW_FADE_MS, easing: 'ease-out', fill: 'forwards', + }) + this.exits.set(key, { element, animation }) + animation.onfinish = () => { this.removeExit(key) } + } + } + + override componentWillUnmount(): void { + this.clear() + } + + private readPositions(): Map { + const list = this.list.current as HTMLDivElement + const rows = list.querySelectorAll('[data-row-key]') + return new Map(Array.from(rows, element => [element.dataset.rowKey as string, { + element, + rect: element.getBoundingClientRect(), + opacity: this.movements.has(element) ? Number(getComputedStyle(element).opacity) : 1, + }])) + } + + private move(element: HTMLElement, keyframes: Keyframe[], duration: number): void { + const animation = element.animate(keyframes, { duration, easing: 'ease-out' }) + this.movements.set(element, animation) + animation.onfinish = () => { this.movements.delete(element); animation.cancel() } + } + + private cancelMovements(): void { + for (const animation of this.movements.values()) { + animation.onfinish = null + animation.cancel() + } + this.movements.clear() + } + + private removeExit(key: string): void { + const exit = this.exits.get(key) + if (exit === undefined) return + exit.animation.onfinish = null + exit.animation.cancel() + exit.element.remove() + this.exits.delete(key) + } + + private clear(): void { + this.cancelMovements() + for (const key of this.exits.keys()) this.removeExit(key) + } + + override render(): ReactNode { + return <> +
{ this.armed = true }} + onKeyDownCapture={() => { this.armed = true }} + > + {this.props.children} +
+ ) } @@ -358,38 +387,58 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; /** * One flat search result: title, Workspace context, and optional content * excerpt. Search navigation opens the session only; it does not address an - * event inside the conversation. + * event inside the conversation. Archived rows carry a hover unarchive + * button, because search is where the filter surfaces them for recovery. * @param props.result - merged local/content search row. * @param props.currentId - selected session id. * @param props.onOpen - open the selected session. + * @param props.onUnarchive - unarchive an archived result row. * @param props.t - Workspace-browser translation seat. - * @returns the result button. + * @returns the result row. */ -export function SearchResultItem({ result, currentId, onOpen, t }: { +export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }: { result: SearchResultNode currentId: string | undefined onOpen: (id: SearchResultNode['id']) => void + onUnarchive: (id: SearchResultNode['id']) => void t: RowTranslate }) { const selected = result.id === currentId const statuses = sessionStatuses(result, t) const primaryStatus = statuses[0] return ( - + + + )} {result.workspace || t('group.ungrouped')} @@ -397,7 +446,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {result.snippet} )} - + ) } @@ -419,7 +468,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { * @returns the session row. */ export function SessionNodeItem({ - node, currentId, now, onOpen, onRename, onFork, onArchive, onReveal, drag, flat = false, t, + node, currentId, now, onOpen, onRename, onFork, onArchive, onUnarchive, onPin, onReveal, drag, flat = false, t, }: { node: SessionNode currentId: string | undefined @@ -431,6 +480,10 @@ export function SessionNodeItem({ onFork: (id: SessionNode['id']) => void /** Archive this session (row menu action; commits without a dialog). */ onArchive: (id: SessionNode['id']) => void + /** Unarchive this session (row menu action on archived rows). */ + onUnarchive: (id: SessionNode['id']) => void + /** Pin or unpin this session (row menu action; `pin` false unpins). */ + onPin: (id: SessionNode['id'], pin: boolean) => void /** Scroll this row into view after search navigation, then acknowledge it. */ onReveal?: (() => void) | undefined /** Present on reorderable-list rows so every row can remain a drop target. */ @@ -445,7 +498,10 @@ export function SessionNodeItem({ const statuses = sessionStatuses(node, t) const primaryStatus = statuses[0] const showStatus = primaryStatus.state !== 'idle' - const draggable = drag !== undefined && !row.blank + // Archived rows hold their in-place grayed slot, so manual reorder cannot + // move them. Pinned rows drag within the pinned block: the browser gates + // their drop targets to fellow pinned rows. + const draggable = drag !== undefined && !row.blank && !row.archived const [menuOpen, setMenuOpen] = useState(false) const rowRef = useRef(null) const titleRef = useRef(null) @@ -456,35 +512,49 @@ export function SessionNodeItem({ }, [onReveal]) // Archive hides the row through the registry-global archive set and never // touches the session log, so it is not styled as destructive and needs no - // confirmation dialog. - const sessionMenuItems = [ - { id: 'rename', label: t('rename'), icon: }, - { id: 'fork', label: t('menu.fork'), icon: }, - { id: 'archive', label: t('menu.archiveSession'), icon: }, - ] + // confirmation dialog. Archived rows swap pin/archive for unarchive. + const sessionMenuItems = row.archived + ? [ + { id: 'rename', label: t('rename'), icon: }, + { id: 'fork', label: t('menu.fork'), icon: }, + { id: 'unarchive', label: t('menu.unarchiveSession'), icon: }, + ] + : [ + { + id: row.pinned ? 'unpin' : 'pin', + label: t(row.pinned ? 'menu.unpinSession' : 'menu.pinSession'), + icon: row.pinned ? : , + }, + { id: 'rename', label: t('rename'), icon: }, + { id: 'fork', label: t('menu.fork'), icon: }, + { id: 'archive', label: t('menu.archiveSession'), icon: }, + ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = (
{ onOpen(node.id) }} onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }} onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }} draggable={draggable} - onDragStart={drag === undefined || row.blank + onDragStart={!draggable ? undefined : (e) => { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', node.id) drag.start() }} - onDragEnd={drag === undefined || row.blank ? undefined : drag.end} + onDragEnd={!draggable ? undefined : drag.end} onDragOver={drag === undefined ? undefined : (e) => { @@ -503,13 +573,23 @@ export function SessionNodeItem({ > {/* Pending interaction and own or descendant activity outrank the finished-but-unviewed reminder, which returns after activity stops - and is cleared by opening the session. */} + and is cleared by opening the session. Archived rows keep the slot + blank — the grayed row carries the archived look — and their live + status stays on the hover card only. */} {(!flat || showStatus) && ( - {showStatus && } + {!row.archived && showStatus && } )} - {title} + { e.stopPropagation(); onRename(node.id, row.title) }} + > + {title} + {row.hasActiveSchedule && } {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs @@ -523,6 +603,9 @@ export function SessionNodeItem({ {primaryStatus.trailingLabel ?? timeLabel(row.updatedAt, now, t)} )} + {/* Trails the time so the marker occupies the same right-edge cell as + the hover pin button that replaces it. */} + {row.pinned && !row.archived && } {!row.blank && ( )} /> + + + + {/* Rightmost so it lands where the rest-state pin marker sits. */} + {!row.archived && ( + + + + )} )}
@@ -556,6 +669,7 @@ export function SessionNodeItem({ } + openDelayMs={800} disabled={menuOpen || drag?.active === true} copyText={row.blank ? undefined : row.title} copyLabel={t('copy')} diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 91ad5eb779..930f492b94 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -34,6 +34,12 @@ background: var(--dsw-alias-interactive-bg-hover); } +/* View-options dropdown card: labels are short, so a content-sized card reads + cramped and its width shifts with the locale — hold a stable floor. */ +.viewOptionsMenu { + min-width: 200px; +} + /* Section header: title, an inline search control, and the two trailing actions. Expanding search collapses the action cluster and takes its room. */ .sectionHeader { @@ -368,16 +374,64 @@ margin-top: 2px; } -.searchStatus, -.searchWarning { +.searchStatus { padding: 10px 12px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); } -.searchWarning { - color: var(--dsw-alias-label-secondary); +/* Pending content search: skeleton rows on searchResultRow metrics — a round + leading glyph beside a title bar and a wider snippet bar, breathing through + opacity (same pulse as the input-trigger menu skeleton). */ +.skeletonRow { + display: flex; + /* Top alignment lines the 16px dot up with the equally tall title bar; + the paddings center the 35px content in the 48px row. */ + align-items: flex-start; + gap: 8px; + box-sizing: border-box; + min-height: 48px; + padding: 6px 8px 7px; +} + +.skeletonDot { + flex: none; + width: 16px; + height: 16px; + border-radius: 50%; + corner-shape: round; +} + +.skeletonBars { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.skeletonDot, +.skeletonBar { + background: var(--dsw-alias-bg-skeleton); + animation: search-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; +} + +.skeletonBar { + width: 65%; + height: 16px; + border-radius: 4px; +} + +.skeletonBarWide { + width: 90%; + height: 13px; +} + +@keyframes search-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } } /* One workspace section: header row + a compact expanded session run. */ @@ -507,6 +561,11 @@ animation: none; } + .skeletonDot, + .skeletonBar { + animation: none; + } + .search, .sectionLabel, .searchSlot, diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 784391e45d..10224356ca 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -12,8 +12,11 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCloseFillRegular, IconPersonalizationOutlineRegular, - IconProjectAddOutlineRegular, IconSearchOutlineRegular, Menu, Modal, Tooltip, + Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, + IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular, + IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular, + IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, IconWarningOutlineRegular, + IconWorkspaceTreeOutlineRegular, Menu, Modal, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState, SessionSearchResultItem, @@ -21,12 +24,13 @@ import type { import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceBrowserProps } from '../contract/slots.ts' -import type { GroupNode, SessionNode, SessionOrderBy } from '../tree.ts' +import type { ArchivedFilter, GroupNode, SessionNode, SessionOrderBy, SessionRowState } from '../tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder, - pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY, visibleSessionIds, + pinCurrentBlank, reconcileManualOrder, sessionMemberIds, UNGROUPED_KEY, } from '../tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx' +import { AnimatedRows } from './AnimatedRows.tsx' import { FLAT_SESSION_ORDER_KEY, type SessionGroupBy } from '../stores.ts' import { WorkspacePickFlow } from '../WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' @@ -96,37 +100,64 @@ function useNativeDragAcceptance(active: boolean): void { }, [active]) } -/** Grouping and ordering menu; own open state so it resets with the wide chrome. */ -function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { +/** Grouping, ordering, and archived-filter menu; own open state so it resets with the wide chrome. */ +function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrderPick, onArchivedFilterPick, openSeq, t }: { groupBy: SessionGroupBy orderBy: SessionOrderBy + archivedFilter: ArchivedFilter onGroupPick: (mode: SessionGroupBy) => void onOrderPick: (mode: SessionOrderBy) => void + onArchivedFilterPick: (filter: ArchivedFilter) => void + /** Each bump above zero opens the menu (the archive toast's filter action). */ + openSeq?: number t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) + // Seeded with the mount-time value: a chrome remount (e.g. window resize + // through the rail breakpoint) must not replay an old toast-action bump. + const seenOpenSeq = useRef(openSeq ?? 0) + useEffect(() => { + if (openSeq !== undefined && openSeq > seenOpenSeq.current) { + seenOpenSeq.current = openSeq + setOpen(true) + } + }, [openSeq]) return ( { setOpen(false) }} items={[ { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, - { id: 'workspace', label: t('groupBy.workspace') }, - { id: 'workspace-tree', label: t('groupBy.workspaceTree') }, - { id: 'flat', label: t('groupBy.flat') }, + { id: 'workspace', label: t('groupBy.workspace'), icon: }, + { id: 'workspace-tree', label: t('groupBy.workspaceTree'), icon: }, + { id: 'flat', label: t('groupBy.flat'), icon: }, { type: 'separator' as const, id: 'order-by-separator' }, { type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, - { id: 'manual', label: t('orderBy.manual') }, - { id: 'updated', label: t('orderBy.updated') }, + { id: 'manual', label: t('orderBy.manual'), icon: }, + { id: 'updated', label: t('orderBy.updated'), icon: }, + { type: 'separator' as const, id: 'archived-filter-separator' }, + { type: 'label' as const, id: 'filter-by', text: t('filterBy.label') }, + { id: 'show-archived', label: t('viewOptions.showArchived'), icon: }, + { id: 'only-archived', label: t('viewOptions.onlyArchived'), icon: }, + ]} + selectedIds={[ + groupBy, + orderBy, + ...archivedFilter === 'show' ? ['show-archived'] : [], + ...archivedFilter === 'only' ? ['only-archived'] : [], ]} - selectedIds={[groupBy, orderBy]} onSelect={(id) => { if (id === 'workspace' || id === 'workspace-tree' || id === 'flat') onGroupPick(id) else if (id === 'manual' || id === 'updated') onOrderPick(id) + // The two archived items are mutually exclusive; re-picking the + // selected one returns to the default hide-archived view. + else if (id === 'show-archived') onArchivedFilterPick(archivedFilter === 'show' ? 'default' : 'show') + else if (id === 'only-archived') onArchivedFilterPick(archivedFilter === 'only' ? 'default' : 'only') setOpen(false) }} align="end" dense + listClassName={css.viewOptionsMenu} // Portal: the section header clips overflow, so an in-place list would // be cut off at the header's bounds. portal @@ -138,7 +169,7 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { aria-label={t('viewOptions.label')} onClick={() => { setOpen(v => !v) }} > - + )} @@ -146,15 +177,44 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { ) } +/** Hold for the actionable post-archive toast: two buttons need a longer read-and-react window than a plain notice. */ +const ARCHIVE_TOAST_HOLD_MS = 6000 + /** In-flight root-row drag: source identity plus the current insert marker. */ interface DragState { /** Workspace id, or {@link UNGROUPED_KEY} for the browser-local loose-session account. */ accountKey: string sessionId: SessionNode['id'] + /** Source row was pinned at drag start; drop targets share the pinned block. */ + pinned: boolean /** Row the marker sits on and which half (insert above/below it). */ over: { id: SessionNode['id']; half: 'before' | 'after' } | null } +/** Apply a visible drop to the complete account without removing hidden members. */ +function sessionDragOrder( + order: readonly SessionId[], + rows: readonly SessionNode[], + drag: DragState, + over: NonNullable, +): SessionId[] | undefined { + const source = rows.find(row => row.id === drag.sessionId) + const target = rows.find(row => row.id === over.id) + if (source === undefined || target === undefined || source.blank + || source.pinned !== drag.pinned || target.pinned !== drag.pinned + || source.id === target.id || !order.includes(source.id)) return + const section = rows.filter(row => row.pinned === drag.pinned) + const sourceIndex = section.findIndex(row => row.id === source.id) + const withoutSource = section.filter(row => row.id !== source.id) + const insertAt = withoutSource.findIndex(row => row.id === target.id) + (over.half === 'after' ? 1 : 0) + if (insertAt === sourceIndex) return + const next = order.filter(id => id !== source.id) + const targetIndex = next.indexOf(target.id) + if (targetIndex === -1) return + next.splice(targetIndex + (over.half === 'after' ? 1 : 0), 0, source.id) + return pinCurrentBlank(next, rows.find(row => row.blank)?.id) +} + /** In-flight Workspace-row drag: source identity plus the current marker. */ interface WorkspaceDragState { workspaceId: WorkspaceId @@ -182,6 +242,8 @@ type SessionTreeProps = Pick< ungroupedSessionIds: readonly SessionId[] /** Whether the current Workspace stream has a complete Host baseline. */ workspaceReady: boolean + /** Grouping, ordering, and filter changes replace the view without row motion. */ + animationResetKey: string /** Nest Workspaces under their nearest registered ancestors. */ nestWorkspaces: boolean /** Explicit persisted group expansion, including descendants in tree mode. */ @@ -190,16 +252,20 @@ type SessionTreeProps = Pick< setGroupExpanded: (key: string, expanded: boolean) => void /** Save a drag order and select Manual. */ setSessionOrder: (accountKey: string, order: readonly string[]) => void - /** Registry-global archive set (hidden rows). */ - archivedSessionIds: readonly SessionNode['id'][] + /** Registry-global pin and archive sets plus the archived-visibility choice. */ + rowState: SessionRowState /** Open the browser-owned rename dialog for a real Workspace group. */ onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned session rename dialog. */ onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void - /** Archive a session (row menu action; the row disappears on the state echo). */ + /** Archive a Session; the selected filter determines its visibility. */ onSessionArchive: (sessionId: SessionNode['id']) => void + /** Unarchive a session (row menu action on archived rows). */ + onSessionUnarchive: (sessionId: SessionNode['id']) => void + /** Pin or unpin a session (row menu action; `pin` false unpins). */ + onSessionPin: (sessionId: SessionNode['id'], pin: boolean) => void /** One Session chosen from search that must be exposed and scrolled into view. */ revealSessionId?: SessionId | undefined /** Acknowledge that the chosen Session row has been revealed. */ @@ -209,9 +275,9 @@ type SessionTreeProps = Pick< /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ list, useSessionStatus, startSession, open, forkSession, workspaces, ungroupedSessionIds, - archivedSessionIds, - workspaceReady, usePanelInfo, - onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, + rowState, + workspaceReady, animationResetKey, usePanelInfo, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, onSessionUnarchive, onSessionPin, insertWorkspaceBefore, nestWorkspaces, groupExpansion, setGroupExpanded, setSessionOrder, home, t, @@ -262,11 +328,11 @@ function SessionTree({ .filter(key => groupExpansion[key] ?? ancestorKeys.has(key)) }, [groupExpansion, parents, workspaces]) const groups = useMemo( - () => deriveGroups(list, workspaces, archivedSessionIds, statuses, { + () => deriveGroups(list, workspaces, rowState, statuses, { expandedGroups, ungroupedOrder: ungroupedSessionIds, }), - [list, workspaces, archivedSessionIds, statuses, expandedGroups, ungroupedSessionIds], + [list, workspaces, rowState, statuses, expandedGroups, ungroupedSessionIds], ) useEffect(() => { for (let key = revealGroup; key !== undefined; key = parents.get(key)) { @@ -289,48 +355,15 @@ function SessionTree({ setDrag(null) const group = groups.find(candidate => candidate.key === activeDrag.accountKey) if (group === undefined) return - const sessionsExpanded = expandedSessionGroups.includes(group.key) - const renderedSessions = sessionsExpanded ? group.sessions : collapsedSessionRows(group.sessions).rows - const targetIndex = renderedSessions.findIndex(session => session.id === over.id) - if (targetIndex === -1) return - const sourceIndex = renderedSessions.findIndex(session => session.id === activeDrag.sessionId) if (over.id === activeDrag.sessionId) return - const withoutSource = renderedSessions.filter(session => session.id !== activeDrag.sessionId) - const targetWithoutSourceIndex = withoutSource.findIndex(session => session.id === over.id) - if (targetWithoutSourceIndex === -1) return - const visibleInsertAt = over.half === 'before' ? targetWithoutSourceIndex : targetWithoutSourceIndex + 1 - if (sourceIndex !== -1 && visibleInsertAt === sourceIndex) return const accountSessionIds = activeDrag.accountKey === UNGROUPED_KEY ? ungroupedSessionIds : workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds - if (accountSessionIds === undefined || !accountSessionIds.includes(activeDrag.sessionId)) return - const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId) - let anchor: SessionId | undefined - if (sessionsExpanded) { - anchor = over.half === 'before' ? over.id : renderedSessions[targetIndex + 1]?.id - } else { - // Place the source at the visible boundary before hidden account members. - const previousVisible = withoutSource[visibleInsertAt - 1]?.id - if (previousVisible === undefined) { - anchor = nextOrder[0] - } else { - const previousIndex = nextOrder.indexOf(previousVisible) - if (previousIndex === -1) return - anchor = nextOrder[previousIndex + 1] - } - } - const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) - nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) - if (!sessionsExpanded && sourceIndex !== -1) { - const nodes = new Map(group.sessions.map(node => [node.id, node])) - const nextGroup = nextOrder.flatMap((id) => { - const node = nodes.get(id) - return node === undefined ? [] : [node] - }) - if (!collapsedSessionRows(nextGroup).rows.some(node => node.id === activeDrag.sessionId)) return - } - const currentBlank = group.sessions.find(node => node.blank)?.id - setSessionOrder(activeDrag.accountKey, pinCurrentBlank(nextOrder, currentBlank)) + if (accountSessionIds === undefined) return + const sessionsExpanded = expandedSessionGroups.includes(group.key) + const renderedSessions = sessionsExpanded ? group.sessions : collapsedSessionRows(group.sessions).rows + const nextOrder = sessionDragOrder(accountSessionIds, renderedSessions, activeDrag, over) + if (nextOrder !== undefined) setSessionOrder(activeDrag.accountKey, nextOrder) } const commitWorkspaceDrag = ( activeDrag: WorkspaceDragState, @@ -369,12 +402,18 @@ function SessionTree({ && workspaceDrag?.over?.id === rootGroups[0].workspaceId && workspaceDrag.over.half === 'before' + const rowKeys: string[] = groups.length === 0 ? ['empty'] : [] const renderGroup = (group: GroupNode, depth: number): ReactNode => { const workspaceId = group.workspaceId const children = childrenByParent.get(group.key) ?? [] const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key) const collapsed = collapsedSessionRows(group.sessions) const sessionsExpanded = expandedSessionGroups.includes(group.key) + rowKeys.push(`workspace:${group.key}`) + const childRows = group.expanded ? children.map(child => renderGroup(child, depth + 1)) : [] + const sessions = sessionsExpanded ? group.sessions : collapsed.rows + for (const node of sessions) rowKeys.push(`session:${node.id}`) + if (collapsed.hiddenCount > 0) rowKeys.push(`overflow:${group.key}`) const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId ? workspaceDrag.over.half : null @@ -475,25 +514,24 @@ function SessionTree({ }, }} /> - {group.expanded && children.length > 0 && ( + {childRows.length > 0 && (
- {children.map(child => renderGroup(child, depth + 1))} + {childRows}
)} - {(sessionsExpanded - ? group.sessions - : collapsed.rows - ).map((node) => { - // Session drag never leaves its browser-local account. + {sessions.map((node) => { + // Session drag never leaves its browser-local account, and pinned + // rows reorder only within their leading pinned block. const sameGroupDrag = drag !== null && drag.accountKey === group.key + const compatibleTarget = sameGroupDrag && drag.pinned === node.pinned const normalizeHalf = (half: 'before' | 'after'): 'before' | 'after' => node.blank ? 'after' : half const dragProps = { start: () => { sessionDropCommitted.current = false - setDrag({ accountKey: group.key, sessionId: node.id, over: null }) + setDrag({ accountKey: group.key, sessionId: node.id, pinned: node.pinned, over: null }) }, - active: sameGroupDrag, + active: compatibleTarget, marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, hover: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ @@ -522,6 +560,8 @@ function SessionTree({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} + onUnarchive={onSessionUnarchive} + onPin={onSessionPin} onReveal={node.id === revealSessionId && group.key === revealGroup ? () => { onSessionRevealed(node.id) } : undefined} @@ -534,6 +574,7 @@ function SessionTree({