From 33d496951c8787e5b61a0e6d83fe242d8e7d7289 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 11:08:17 +0800 Subject: [PATCH 01/12] feat(workspace): session pinning and the sidebar archived filter Pin sessions to the top of the sidebar and reach archived sessions through the view-options filter instead of a settings page; archiving gains an undo/filter toast, and row motion stays reserved for pin jumps and archive fades. --- ...-session-pin-and-sidebar-archive.i18n.yaml | 6 + ...6-09-18-session-pin-and-sidebar-archive.md | 32 ++ ...9-18-session-pin-and-sidebar-archive.zh.md | 32 ++ THIRD_PARTY_NOTICES.md | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 - docs/config-catalog.zh.md | 1 - docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 2 - docs/module-graph.zh.md | 2 - .../workspace-controller/src/client/index.ts | 5 +- .../workspace-controller/src/client/model.ts | 67 +++ .../src/client/service.ts | 20 + .../api/workspace-controller/src/commands.ts | 38 +- packages/api/workspace-controller/src/feed.ts | 26 +- .../api/workspace-controller/src/index.ts | 23 + .../api/workspace-controller/src/types.ts | 24 + .../tests/model.client.spec.ts | 107 +++- .../tests/remote/workspace.client.ts | 12 +- .../tests/transport.client.spec.ts | 20 +- .../tests/workspace-controller.host.spec.ts | 49 +- packages/bundle/web-app/cordis.patch.yml | 3 - packages/bundle/web-app/package.json | 1 - .../locale/tests/language-row.client.spec.tsx | 2 +- .../ui-chat/tests/chat-view.client.spec.tsx | 2 +- .../tests/transcript-view-row.client.spec.tsx | 2 +- .../tests/enter-behavior-row.client.spec.tsx | 2 +- .../tests/input-bar.client.spec.tsx | 2 +- .../tests/input-matrix.client.spec.tsx | 2 +- .../tests/input-scenarios.client.spec.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 2 +- .../ui-layout/tests/app-frame.client.spec.tsx | 2 +- .../src/client/FeedbackDialog.module.css | 12 - .../src/client/FeedbackDialog.tsx | 4 +- .../client/ui-primitives/src/Toast.module.css | 32 +- packages/client/ui-primitives/src/Toast.tsx | 34 +- packages/client/ui-primitives/src/Tooltip.tsx | 23 +- .../client/ui-primitives/src/icons/index.tsx | 172 ++++++ .../ui-primitives/tests/icons.client.spec.tsx | 4 +- .../ui-primitives/tests/toast.client.spec.tsx | 30 ++ .../tests/tooltip.client.spec.tsx | 45 +- .../tests/shell.client.spec.ts | 6 +- .../README.i18n.yaml | 6 - .../ui-settings-unarchive-sessions/README.md | 112 ---- .../README.zh.md | 112 ---- .../package.json | 71 --- .../client/ArchivedSessionsSection.module.css | 83 --- .../src/client/ArchivedSessionsSection.tsx | 134 ----- .../src/client/index.ts | 49 -- .../src/client/locales.ts | 42 -- .../src/css-modules.d.ts | 6 - .../src/index.ts | 4 - .../tests/browser-plugin.client.spec.tsx | 85 --- .../tests/components.client.spec.tsx | 158 ------ .../tsconfig.json | 39 -- .../tsdown.config.ts | 3 - .../ui-theme/src/styles/design-platform.css | 6 +- .../tests/appearance-row.client.spec.tsx | 2 +- .../tests/font-size-row.client.spec.tsx | 2 +- .../ui-trajectory/tests/views.client.spec.tsx | 5 +- .../tests/plan-review-panel.client.spec.tsx | 1 + .../user-questions-composer.client.spec.tsx | 1 + packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- packages/client/ui-workspace/package.json | 10 +- .../ui-workspace/src/client/contract/slots.ts | 6 + .../client/ui-workspace/src/client/index.ts | 3 + .../client/ui-workspace/src/client/locales.ts | 38 +- .../src/client/rows/Rows.module.css | 66 ++- .../ui-workspace/src/client/rows/Rows.tsx | 148 ++++- .../client/rows/WorkspaceBrowser.module.css | 101 +++- .../src/client/rows/WorkspaceBrowser.tsx | 505 ++++++++++++++++-- .../client/ui-workspace/src/client/stores.ts | 15 + .../client/ui-workspace/src/client/tree.ts | 140 ++++- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../ui-workspace/tests/rows.client.spec.tsx | 238 +++++++-- .../ui-workspace/tests/tree.client.spec.ts | 167 +++++- .../tests/workspace-browser.client.spec.tsx | 491 +++++++++++++++-- .../tests/workspace-picker.client.spec.tsx | 2 +- .../tests/workspaces-service.client.spec.ts | 3 + packages/client/ui-workspace/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 1 - .../src/assembly/remote-default-responses.ts | 2 +- .../client-runtime/src/fixtures.ts | 1 + .../client-runtime/src/workspaces.ts | 37 ++ packages/workspace/workspace/src/index.ts | 113 +++- packages/workspace/workspace/src/spec.ts | 21 +- .../workspace/tests/workspace.spec.ts | 146 ++++- pnpm-lock.yaml | 65 +-- .../verify-package-readme-model-experience.ts | 1 - tsconfig.base.json | 1 - tsconfig.client.json | 1 - 93 files changed, 2826 insertions(+), 1290 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md create mode 100644 .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md delete mode 100644 packages/client/ui-settings-unarchive-sessions/README.i18n.yaml delete mode 100644 packages/client/ui-settings-unarchive-sessions/README.md delete mode 100644 packages/client/ui-settings-unarchive-sessions/README.zh.md delete mode 100644 packages/client/ui-settings-unarchive-sessions/package.json delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.module.css delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/client/ArchivedSessionsSection.tsx delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/client/index.ts delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/client/locales.ts delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/css-modules.d.ts delete mode 100644 packages/client/ui-settings-unarchive-sessions/src/index.ts delete mode 100644 packages/client/ui-settings-unarchive-sessions/tests/browser-plugin.client.spec.tsx delete mode 100644 packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx delete mode 100644 packages/client/ui-settings-unarchive-sessions/tsconfig.json delete mode 100644 packages/client/ui-settings-unarchive-sessions/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml new file mode 100644 index 0000000000..55ca1b1eed --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml @@ -0,0 +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-18-session-pin-and-sidebar-archive.md +2026-09-18-session-pin-and-sidebar-archive.md: 57bace16ce7171935fe71de9751da5c21bf1e3cf +2026-09-18-session-pin-and-sidebar-archive.zh.md: a752e2d962ef844ac062c48c9d30ef4134e60e95 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..57bace16ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md @@ -0,0 +1,32 @@ +# 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. + +## Decision + +The `dsh-workspace` registry stores a registry-global pin set (`pinnedSessions`, most recently pinned first, each entry carrying its epoch-millisecond `pinnedAt`) beside the existing archive set, with durable `pinSession`/`unpinSession` operations. Pinning and archival are mutually exclusive: archiving drops the session's pin in the same durable write, and pinning an archived session fails with `WorkspaceArchivedSessionPinError`. + +The sidebar derives both states in `ui-workspace`: + +- Pinned rows lead their section as a partition that never disturbs relative order on either side, so unpinning restores the row's kept slot. Under recency order a pinned row ranks by the later of its pin instant and update recency; under manual order pinned-to-pinned drags hold. +- Archived rows keep their accounting slots, render grayed, and are not openable. The view-options menu owns an `ArchivedFilter` (`default` hide / `show` / `only`) applied to lists and search alike; the settings-page archived list is deleted with its package. +- Archiving raises a toast with undo and filter-archived actions, and a one-time hint anchors under the view-options trigger. +- Row motion uses `@formkit/auto-animate` reserved for pin jumps and archive/unarchive fades; wholesale layout passes (expand/collapse, drag commit, grouping/ordering/filter switches) mute row animations for one frame through `muteNextRowAnimations`. + +## Alternatives considered + +**Keep the settings-page archived list.** Two homes for one state; the sidebar filter shows archived rows in the browsing context they came from, so the package is removed outright. + +**Fold pin position into the persisted manual order.** The order would absorb pin state, so unpinning could not restore the row's previous slot; the partition keeps pin state and row order independent. + +**Animate every list pass with auto-animate defaults.** Expanding a group, committing a drag, or switching the filter moves rows wholesale and reads as exaggerated motion; those passes apply instantly and animation stays reserved for single-row pin and archive changes. + +## 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. `ui-workspace` gains a client dependency on `@formkit/auto-animate`. Unit suites pin the partition, filter, toast, and mute behavior; the workspace Host suite pins pin/archive exclusivity and durability. 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..a752e2d962 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md @@ -0,0 +1,32 @@ +# 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`)访问。用户归档一个会话之后就再也看不到它了。 + +## Decision + +`dsh-workspace` registry 在既有归档集合旁存储一个 registry 全局置顶集合(`pinnedSessions`,最近置顶在前,每项携带毫秒级 `pinnedAt`),并提供持久化的 `pinSession`/`unpinSession` 操作。置顶与归档互斥:归档会在同一次持久化写入中移除该会话的置顶,置顶已归档会话则以 `WorkspaceArchivedSessionPinError` 失败。 + +侧边栏在 `ui-workspace` 中派生这两种状态: + +- 置顶行以分区方式排在所在区块最前,分区不打乱两侧的相对顺序,因此取消置顶会恢复行原有的位置。最近更新排序下,置顶行按置顶时间与更新时间中较晚者排名;手动排序下,置顶行之间的拖拽顺序保持。 +- 已归档行保留其记账位置,置灰渲染且不可打开。视图选项菜单持有 `ArchivedFilter`(`default` 隐藏 / `show` / `only`),同时作用于列表与搜索;设置页的归档列表连同其包一并删除。 +- 归档动作弹出携带撤销与筛选已归档操作的 toast,并在视图选项触发按钮下方锚定一次性提示。 +- 行动画使用 `@formkit/auto-animate`,仅保留给置顶跳动与归档/取消归档淡入淡出;整体布局变化(展开/收起、拖拽提交、分组/排序/筛选切换)通过 `muteNextRowAnimations` 将行动画静默一帧。 + +## Alternatives considered + +**保留设置页的归档列表。** 同一状态出现两个入口;侧边栏筛选让已归档行显示在它们原本所在的浏览上下文中,因此该包被整体移除。 + +**把置顶位置并入持久化的手动排序。** 排序会吸收置顶状态,取消置顶后无法恢复行先前的位置;分区让置顶状态与行顺序保持独立。 + +**用 auto-animate 默认行为为每次列表变化播放动画。** 展开分组、提交拖拽或切换筛选会整体移动行,观感夸张;这些变化即时应用,动画只保留给单行的置顶与归档变化。 + +## Consequences + +侧边栏端到端拥有已归档可见性,设置页少了一个包。置顶集合是 registry 全局的,因此置顶在分组与排序切换后仍然保留。`ui-workspace` 新增 client 依赖 `@formkit/auto-animate`。单元测试钉住分区、筛选、toast 与静默行为;workspace Host 测试钉住置顶/归档互斥与持久化。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ec698dca33..2eae5be989 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,6 +38,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | | [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | +| [`@formkit/auto-animate`](https://github.com/formkit/auto-animate) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@lexical/history`](https://github.com/facebook/lexical) | MIT | 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/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index fffa05c18f..94fe394182 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -17,7 +17,7 @@ export type { } from './model.ts' export { WorkspaceController, WorkspaceCreateError } from './service.ts' export type { IWorkspaces, WorkspaceSource } from './service.ts' -export type { WorkspaceId, WorkspaceView } from '../types.ts' +export type { WorkspaceId, WorkspacePinnedSession, WorkspaceView } from '../types.ts' type WorkspaceBaselineFrame = Extract @@ -107,6 +107,9 @@ function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncr case 'archived': accept.replaceArchived(frame.archivedSessionIds) return + case 'pinned': + accept.replacePinned(frame.pinnedSessions) + 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..bab0e2f85c 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 pinnedSessions: WorkspacePinValue['pinnedSessions'] 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(pinnedSessions: WorkspacePinValue['pinnedSessions']): void } /** @@ -55,6 +62,7 @@ export interface WorkspaceFollowSink { export class ClientWorkspaceModel implements WorkspaceFollowSink { private items: readonly WorkspaceView[] = [] private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = [] + private pinnedSessions: WorkspacePinValue['pinnedSessions'] = [] 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.pinnedSessions.filter(entry => entry.sessionId !== 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.pinnedSessions) + } + 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.pinnedSessions) + } + 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.pinnedSessions) 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 pinnedSessions - complete Host-confirmed pin set, most recently pinned first. + */ + replacePinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void { + this.pinRequestSeq++ + this.installPinned(pinnedSessions) + } + /** 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, + pinnedSessions: this.pinnedSessions, state: this.state, phase: this.phase, error: this.error, @@ -288,6 +347,14 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { this.invalidate() } + private installPinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void { + if (pinnedSessions.length === this.pinnedSessions.length + && pinnedSessions.every((entry, index) => entry.sessionId === this.pinnedSessions[index]?.sessionId + && entry.pinnedAt === this.pinnedSessions[index]?.pinnedAt)) return + this.pinnedSessions = [...pinnedSessions] + 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..58608215a7 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 + /** + * Unpin a Session back to its natural list position. + * @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..2d922fac08 100644 --- a/packages/api/workspace-controller/src/commands.ts +++ b/packages/api/workspace-controller/src/commands.ts @@ -3,13 +3,14 @@ import type { Context } from '@deepseek-ai/cordis' import type { Workspace } from '@deepseek-ai/dsh-workspace' import { + WorkspaceArchivedSessionPinError, WorkspaceId, WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' -import { workspaceView } from './feed.ts' +import { pinnedSessionViews, workspaceView } from './feed.ts' import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, @@ -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 { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) } + } + + /** + * 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 { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) } + } + 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..ef931ac7d3 100644 --- a/packages/api/workspace-controller/src/feed.ts +++ b/packages/api/workspace-controller/src/feed.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import { Deque } from '@deepseek-ai/dsh-deque' import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' -import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' +import type { PinnedSession, Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, @@ -12,6 +12,7 @@ import { import type { WorkspaceBaseline, WorkspaceFollowFrame, + WorkspacePinnedSession, WorkspaceView, } from './types.ts' @@ -43,12 +44,26 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie } } +/** + * Project registry pin entries into their Remote values. + * @param entries - authoritative registry pin entries. + * @returns detached pin projections for Remote consumers. + */ +export function pinnedSessionViews(entries: readonly PinnedSession[]): WorkspacePinnedSession[] { + return entries.map(entry => ({ sessionId: entry.id, pinnedAt: entry.pinnedAt })) +} + +/** One comparable key per pin entry; pinnedAt participates so a re-pin at a new instant publishes. */ +const pinKeys = (entries: readonly PinnedSession[]): string[] => + entries.map(entry => `${entry.id}@${entry.pinnedAt}`) + /** Owns Workspace domain observation and all active follow generations. */ export class WorkspaceFeed { private readonly followers = new Set() 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 +71,7 @@ export class WorkspaceFeed { this.knownIds = new Set(baseline.map(workspace => String(workspace.id))) this.order = baseline.map(workspace => String(workspace.id)) this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String) + this.pinned = pinKeys(ctx.workspaceRegistry.pinnedSessions) ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) }) ctx.effect(() => () => { for (const follower of this.followers) follower.close() @@ -65,12 +81,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], + pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions), } } @@ -115,6 +132,11 @@ export class WorkspaceFeed { this.archived = nextArchived this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] }) } + const nextPinned = pinKeys(state.pinnedSessions) + if (!sameStrings(this.pinned, nextPinned)) { + this.pinned = nextPinned + this.publish({ type: 'pinned', pinnedSessions: pinnedSessionViews(state.pinnedSessions) }) + } 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..ff433d8414 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) + } + + /** + * Return one pinned Session to its natural list position. + * @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..41dfc29a87 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -114,10 +114,33 @@ 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 unpinning back to its natural list position. */ +export interface WorkspaceUnpinSessionRequest { + readonly sessionId: SessionId +} + +/** One pinned Session entry: the Session and the epoch-millisecond instant it was pinned at. */ +export interface WorkspacePinnedSession { + readonly sessionId: SessionId + readonly pinnedAt: number +} + +/** Complete pinned Session set after a mutation, most recently pinned first. */ +export interface WorkspacePinValue { + readonly pinnedSessions: readonly WorkspacePinnedSession[] +} + /** 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 pinnedSessions: readonly WorkspacePinnedSession[] } /** One ordered Workspace change after a generation's baseline. */ @@ -126,6 +149,7 @@ export type WorkspaceFollowIncrement = | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + | { readonly type: 'pinned'; readonly pinnedSessions: readonly WorkspacePinnedSession[] } /** 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..74b1f45285 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -13,10 +13,14 @@ import type { WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, WorkspaceRenameRequest, WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, WorkspaceValue, WorkspaceId, + WorkspacePinnedSession, WorkspaceView, } from '../src/types.ts' import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' @@ -24,6 +28,9 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' const sid = (id: string): SessionId => id as SessionId const wid = (id: string): WorkspaceId => id as WorkspaceId +const pin = (id: string, pinnedAt = 1): WorkspacePinnedSession => ({ sessionId: sid(id), pinnedAt }) +const pinnedIds = (entries: readonly WorkspacePinnedSession[]): string[] => + entries.map(entry => String(entry.sessionId)) function workspace( id: string, @@ -89,6 +96,14 @@ class FakeWorkspaceRemote implements WorkspaceRemote { request: WorkspaceUnarchiveSessionRequest, ) => Promise> = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] })) + onPinSession: ( + request: WorkspacePinSessionRequest, + ) => Promise> = request => + Promise.resolve(remoteOk({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] })) + onUnpinSession: ( + _request: WorkspaceUnpinSessionRequest, + ) => Promise> = () => + Promise.resolve(remoteOk({ pinnedSessions: [] })) create(request: WorkspaceCreateRequest): Promise> { this.record('create', request) @@ -125,6 +140,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 +165,9 @@ function baseline( model: ClientWorkspaceModel, items: readonly WorkspaceView[] = [], archivedSessionIds: readonly SessionId[] = [], + pinnedSessions: readonly WorkspacePinnedSession[] = [], ): void { - model.replaceBaseline({ items, archivedSessionIds }) + model.replaceBaseline({ items, archivedSessionIds, pinnedSessions }) } describe('ClientWorkspaceModel', () => { @@ -393,6 +419,85 @@ 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, [], [], [pin('kept')]) + + remote.onPinSession = () => Promise.resolve(workspaceError( + new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }), + )) + await expect(model.pinSession(sid('missing'))).resolves.toMatchObject({ ok: false }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept']) + remote.onPinSession = request => Promise.resolve(remoteOk({ + pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 2 }, pin('kept')], + })) + await expect(model.pinSession(sid('fresh'))).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['fresh', 'kept']) + expect(remote.calls).toContainEqual({ method: 'pinSession', request: { sessionId: 'fresh' } }) + + remote.onUnpinSession = () => Promise.resolve(workspaceError( + new RemoteError('gateway/internal', 'wire down', {}), + )) + await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: false }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['fresh', 'kept']) + remote.onUnpinSession = () => Promise.resolve(remoteOk({ pinnedSessions: [pin('kept')] })) + await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept']) + expect(remote.calls).toContainEqual({ method: 'unpinSession', request: { sessionId: 'fresh' } }) + + // A pushed set identical to the local one publishes no new snapshot; the + // same member re-pinned at a new instant does. + const before = model.getSnapshot() + model.replacePinned([pin('kept')]) + expect(model.getSnapshot()).toBe(before) + model.replacePinned([pin('kept', 9)]) + expect(model.getSnapshot()).not.toBe(before) + expect(model.getSnapshot().pinnedSessions).toEqual([pin('kept', 9)]) + }) + + 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({ pinnedSessions: [pin('second', 2), pin('first')] })) + await expect(second).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['second', 'first']) + firstGate.resolve(remoteOk({ pinnedSessions: [pin('first')] })) + await expect(first).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['second', 'first']) + }) + + it('keeps a pushed pin set when an unpin reply lands later', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [], [], [pin('first')]) + const gate = deferred>() + remote.onUnpinSession = () => gate.promise + + const pending = model.unpinSession(sid('first')) + model.replacePinned([pin('first'), pin('second', 2)]) + gate.resolve(remoteOk({ pinnedSessions: [] })) + await expect(pending).resolves.toMatchObject({ ok: true }) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['first', 'second']) + }) + + it('mirrors the Host pin drop locally when an archive echo lands', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [], [], [pin('pinned'), pin('kept')]) + + await expect(model.archiveSession(sid('pinned'))).resolves.toMatchObject({ ok: true }) + expect(model.getSnapshot().archivedSessionIds).toEqual(['pinned']) + expect(pinnedIds(model.getSnapshot().pinnedSessions)).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..e65fdec1df 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: [], pinnedSessions: [] }, + } } /** @@ -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({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] }), + 'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult => ok({ pinnedSessions: [] }), }, } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 67cc99c985..60c833b47f 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', pinnedSessions: [{ sessionId: sid('session-two'), pinnedAt: 1 }] }, ] 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([{ sessionId: 'session-two', pinnedAt: 1 }]) 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: [], pinnedSessions: [] }) 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..9947228f8b 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({ pinnedSessions: [{ sessionId: session.id, pinnedAt: expect.any(Number) }] }) + 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({ pinnedSessions: [] }) + // Unpin is idempotent: an id that is not pinned is not an error. + await expect(controller.unpinSession({ sessionId: session.id })) + .resolves.toEqual({ pinnedSessions: [] }) + }) }) describe('WorkspaceController follow', () => { @@ -253,6 +290,7 @@ describe('WorkspaceController follow', () => { initialized: true, workspaceIds: ['missing'], archivedSessionIds: [], + pinnedSessions: [], }, }) }).toThrow('references missing Workspace "missing"') @@ -264,7 +302,7 @@ describe('WorkspaceController follow', () => { const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]() await expect(nextFrame(iterator)).resolves.toEqual({ type: 'baseline', - value: { items: [], archivedSessionIds: [] }, + value: { items: [], archivedSessionIds: [], pinnedSessions: [] }, }) const first = await controller.create({ path: stageDir(root, 'first') }) @@ -307,6 +345,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', pinnedSessions: [{ sessionId: session.id, pinnedAt: expect.any(Number) }], + }) + // Unpin rides the same complete-set increment: no new frame type. + await controller.unpinSession({ sessionId: session.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'pinned', pinnedSessions: [], + }) 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..31b763ced4 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: [], pinnedSessions: [], 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..ac9e3fe127 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: [], pinnedSessions: [], 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..c2ed0d1baa 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: [], pinnedSessions: [], 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..5feacea703 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: [], pinnedSessions: [], 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..e9dbc5a61f 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: [], pinnedSessions: [], 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..1656cc244b 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: [], pinnedSessions: [], 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..5d04eb8944 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: [], pinnedSessions: [], 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..3c4159c806 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: [], pinnedSessions: [], 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..b7260eba83 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: [], pinnedSessions: [], 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/src/Toast.module.css b/packages/client/ui-primitives/src/Toast.module.css index 793cbe23a1..b8c09caf9f 100644 --- a/packages/client/ui-primitives/src/Toast.module.css +++ b/packages/client/ui-primitives/src/Toast.module.css @@ -23,8 +23,11 @@ max-width: min(640px, calc(100vw - 48px)); padding: 12px 16px; border-radius: 14px; - background: var(--dsw-alias-button-contrast-fill); - color: var(--dsw-alias-label-primary-inverted); + /* The design platform's toast surface is dark in BOTH themes (bluish-800 + light / bluish-750 dark), so the label and the action text below stay + fixed instead of flipping with the theme. */ + background: var(--dsw-alias-toast-bg); + color: var(--dsw-static-neutral-bluish-00); font-size: 14px; line-height: 22px; box-shadow: var(--dsw-shadow-lv3); @@ -41,10 +44,35 @@ color: var(--dsw-alias-state-warn-label); } +.icon.success { + color: var(--dsw-alias-state-success-primary); +} + .text { min-width: 0; } +.action { + display: inline; + padding: 0; + border: 0; + background: none; + /* deepseek-400, brightened at the designer's request, reads as the action + against the always-dark toast surface without an underline. */ + color: var(--dsw-static-deepseek-400); + font: inherit; + cursor: pointer; + /* Breathing room where the action text meets the surrounding copy. */ + margin-inline: 3px; + /* The banner surface stays click-through (`pointer-events: none` above); + only its action text takes the pointer. */ + pointer-events: auto; +} + +.action:hover { + opacity: 0.8; +} + @keyframes dsh-toast-in { from { opacity: 0; diff --git a/packages/client/ui-primitives/src/Toast.tsx b/packages/client/ui-primitives/src/Toast.tsx index 0a50e47230..157c4b9eb7 100644 --- a/packages/client/ui-primitives/src/Toast.tsx +++ b/packages/client/ui-primitives/src/Toast.tsx @@ -1,6 +1,7 @@ -import { useEffect, useLayoutEffect, useState } from 'react' +import { Fragment, useEffect, useLayoutEffect, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' +import { IconCheckCircleOutlineRegular } from './icons/index.tsx' import css from './Toast.module.css' /** Full-opacity hold before the fade starts, when the owner names none. */ @@ -23,7 +24,16 @@ const FADE_MS = 1000 * reads it as a custom property — so the two can no longer disagree and leave * the banner unmounting mid-fade. * @param props.text - resolved banner copy; the owner passes localized text. - * @param props.icon - optional leading glyph (e.g. a warning icon). + * @param props.icon - optional leading glyph (e.g. a warning icon); ignored + * under `tone="success"`, which brings its own glyph. + * @param props.tone - 'success' renders the design's circled green check as + * the leading glyph; omitted, the icon seat keeps its warning tint. + * @param props.actions - optional inline actions continuing the sentence: + * each renders its plain-text `prefix` (a connective like 或) followed by its + * localized `label` as blue clickable text, flowing after `text` as one + * sentence. Each press is the owner's to handle (e.g. undo the reported + * change, then unmount the toast). The banner surface stays click-through — + * only the action text takes the pointer. * @param props.holdMs - full-opacity hold before the fade; defaults to 3000. * @param props.anchor - optional element whose horizontal center the banner * follows (e.g. the composer card, so the banner centers over the chat column @@ -31,11 +41,13 @@ const FADE_MS = 1000 * @param props.onDone - called once the fade completes; unmount the toast here. * @returns the floating banner. */ -export function Toast({ text, icon, anchor, holdMs = HOLD_MS, onDone }: { +export function Toast({ text, icon, tone, anchor, holdMs = HOLD_MS, actions, onDone }: { text: string icon?: ReactNode + tone?: 'success' anchor?: HTMLElement | null holdMs?: number + actions?: readonly { label: string; prefix?: string; onClick: () => void }[] onDone: () => void }) { useEffect(() => { @@ -65,8 +77,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.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 41baf5b785..6f41eb7044 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -20,12 +20,25 @@ 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. @@ -36,7 +49,9 @@ type TooltipLabel = string | (() => string) * @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 }) { const anchor = useRef(null) @@ -168,7 +183,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 && ( diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 749f203439..4333bf43a6 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -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..93b0b2066d 100644 --- a/packages/client/ui-primitives/tests/toast.client.spec.tsx +++ b/packages/client/ui-primitives/tests/toast.client.spec.tsx @@ -59,6 +59,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.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index 26f66958ab..e17eaf532b 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -257,23 +257,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-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..ebdce77d83 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: [], pinnedSessions: [], 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..6f3f0d6a90 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: [], pinnedSessions: [], 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..00be6482a4 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: [], pinnedSessions: [], 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..561c3cb361 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: [], + pinnedSessions: [], 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..81648e06f6 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: [], + pinnedSessions: [], 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..11835528ef 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: f28bdd16143bd48a193a863193302b5dfd1aa345 +README.zh.md: 017dd6ae978c4cd361770103d22a0ca39723d117 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index eabd5dc9eb..f28bdd1614 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -116,7 +116,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..017dd6ae97 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -116,7 +116,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..925d10b551 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,15 @@ "@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:^", + "@formkit/auto-animate": "0.10.0", "@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..ca518bc2fc 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,6 +48,19 @@ 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': '取消置顶失败,请稍后重试', + 'hint.archivedFilter': '已归档的会话可以通过筛选展示', 'sessions.count.one': '{n} 个会话', 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', @@ -92,17 +107,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,6 +137,19 @@ 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.', + 'hint.archivedFilter': 'Archived sessions can be shown with the filter', 'sessions.count.one': '{n} session', 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 0f5792b4c2..77b172a957 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -101,13 +101,12 @@ height: 20px; } -/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ +/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. + Row appearance/removal animates through the browser's auto-animate + containers, so the row itself declares no mount animation. */ .sessionRow { height: 32px; gap: 0; - /* Mount fade: session rows appear by unfolding a group (or the tree - mounting). Stable row keys keep already-visible rows from replaying it. */ - animation: row-in 150ms var(--ds-ease-in-out); } .sessionRow .title { @@ -118,10 +117,6 @@ margin-left: 0; } -@keyframes row-in { - from { opacity: 0; } -} - .slot { flex: none; width: 16px; @@ -240,6 +235,29 @@ margin-left: 4px; } +.pinIndicator { + display: inline-flex; + flex: none; + width: 16px; + height: 20px; + align-items: center; + justify-content: center; + margin-left: 6px; + color: var(--dsw-alias-label-caption); +} + +/* Archived rows stay in place but read as inactive: title and time drop to + the caption label step (one step above dimmed, which reads as too faint + over the sidebar fill). Archived search rows drop their meta line to the + same step so both lines read as one unit. */ +.sessionRow.archived .title, +.sessionRow.archived .time, +.searchResultRow.archived .searchResultTitle, +.searchResultRow.archived .searchResultWorkspace, +.searchResultRow.archived .searchResultSnippet { + color: var(--dsw-alias-label-caption); +} + .dot { flex: none; } @@ -255,16 +273,41 @@ .projectRow:hover .rowActions, .sessionRow:hover .rowActions, +.searchResultRow:hover .rowActions, .projectRow.menuOpen .rowActions, .sessionRow.menuOpen .rowActions { display: inline-flex; } +/* The search heading has no trailing time cell, so the unarchive action + right-aligns itself. */ +.searchResultHeading .rowActions { + margin-left: auto; +} + .sessionRow:hover .time, .sessionRow.menuOpen .time { display: none; } +/* Hover swaps the trailing cell to the action buttons, which already carry + the pin/archive glyphs — hide the static markers so they do not double. */ +.sessionRow:hover .pinIndicator, +.sessionRow.menuOpen .pinIndicator { + display: none; +} + +/* Hover truncation fades the title's own alpha out instead of clipping to + "…": an overlay gradient would need the sidebar's fill, which macOS + vibrancy makes translucent, while a mask works over any backdrop. Short + titles never reach the faded band, so only overflow is affected. */ +.sessionRow:hover .title, +.sessionRow.menuOpen .title { + text-overflow: clip; + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); +} + /* An open row menu pins the hover affordances (figma: the row keeps its hover fill while its dropdown is up). */ .projectRow.menuOpen, @@ -350,6 +393,12 @@ color: #ADB2B8; } +/* Archive glyph rides the status row's text color; slight inset aligns it with the 6px status dots. */ +.hoverArchived svg { + flex-shrink: 0; + margin: 0 -4px; +} + .iconButton { flex: none; display: inline-flex; @@ -376,7 +425,6 @@ } @media (prefers-reduced-motion: reduce) { - .sessionRow, .arrow { animation: none; transition: none; diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 20cc9a4852..c31dfd1560 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -11,8 +11,8 @@ import clsx from 'clsx' import { HoverCard, IconAlarmClockOutlineRegular, IconArchiveOutlineRegular, IconBranchOutlineRegular, IconEditOutlineRegular, IconEllipsisOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, - IconPlusOutlineMedium, IconTrashOutlineRegular, IconTriangleRightFillRegular, Menu, relativeTime, - StateDot, + IconPinFillRegular, IconPinOutlineRegular, IconPlusOutlineMedium, IconTrashOutlineRegular, IconTriangleRightFillRegular, + IconUnarchiveOutlineRegular, Menu, relativeTime, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -336,9 +336,22 @@ function ActiveScheduleIndicator({ t, search = false }: { t: RowTranslate; searc ) } +/** Non-interactive pinned-row marker; the enclosing row remains the only action. */ +function PinnedIndicator({ t }: { t: RowTranslate }) { + const label = t('row.pinned') + return ( + + + + ) +} + /** Hover-card body: full title, relative time, and every relevant live status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { + // On archived rows the archived line already says the session is inactive, + // so resting statuses (idle/completed) drop; live activity still shows. const statuses = sessionStatuses(node, t) + .filter(status => !(node.archived && status.state === 'done')) return (
{displayTitle(node, t)}
@@ -351,6 +364,12 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; {status.label}
))} + {node.archived && ( +
+ + {t('row.archived')} +
+ )} ) } @@ -358,38 +377,55 @@ 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 +433,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {result.snippet} )} - + ) } @@ -419,7 +455,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 +467,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 +485,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,19 +499,32 @@ 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: }, + // 20-native glyph in the menu's 16px icon slot (Menu.module.css .itemIcon). + { 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 = (
{ 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 +559,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 +589,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 && ( { e.stopPropagation(); setMenuOpen(v => !v) }} > - + )} /> + {/* No tooltips on the hover actions: the row's hover card sits right + beside these buttons and the two bubbles collide; the actions + speak through their aria-labels and the resulting toasts. */} + + {/* Rightmost so it lands where the rest-state pin marker sits. */} + {!row.archived && ( + + )} )}
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..87acb02c9a 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -368,16 +368,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. */ @@ -502,11 +550,56 @@ color: var(--dsw-alias-label-secondary); } +/* Post-archive callout bubble (tooltip visual): centered under the + view-options button, sliding inside the viewport when needed while the + arrow stays on the anchor. */ +.archiveHint { + position: fixed; + z-index: 100; + width: max-content; + max-width: 50vw; + padding: 3px 7px; + border-radius: 8px; + background: var(--dsw-alias-tooltip-bg); + color: var(--dsw-static-neutral-bluish-00); + font-size: 13px; + line-height: 20px; + transform: translateX(-50%); + pointer-events: none; + animation: archive-hint-in 150ms var(--ds-ease-in-out); +} + +/* Upward triangle riding the bubble's top edge; its inline `left` tracks the + anchor center when the bubble itself has been slid off-center. */ +.archiveHintArrow { + position: absolute; + top: -5px; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 5px solid var(--dsw-alias-tooltip-bg); +} + +@keyframes archive-hint-in { + from { opacity: 0; } +} + @media (prefers-reduced-motion: reduce) { .wide { animation: none; } + .archiveHint { + 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..9863762645 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -9,11 +9,14 @@ * menu in between; the flow and its error dialog live in WorkspacePicker * (same package — direct composition, no slot between them). */ -import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { type CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' +import autoAnimate, { type AnimationController, type AutoAnimationPlugin } from '@formkit/auto-animate' 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,7 +24,7 @@ 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, @@ -96,33 +99,168 @@ 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 }: { +/** + * First-interaction gate for the row animations: a reload streams the initial + * workspace/session data into freshly mounted containers, and auto-animate + * would play that whole arrival burst as entry animations. Controllers attach + * disabled and arm on the first pointer or keyboard input — every pin, + * archive, or reorder a user can trigger happens after that gate opens. The + * window guard keeps the module loadable where no window exists (node-side + * imports of the plugin entry). + */ +let rowAnimationsArmed = false +const disarmedRowControllers = new Set() +function armRowAnimations(): void { + rowAnimationsArmed = true + window.removeEventListener('pointerdown', armRowAnimations, true) + window.removeEventListener('keydown', armRowAnimations, true) + for (const controller of disarmedRowControllers) controller.enable() + disarmedRowControllers.clear() +} +if (typeof window !== 'undefined') { + window.addEventListener('pointerdown', armRowAnimations, true) + window.addEventListener('keydown', armRowAnimations, true) +} + +/** Fade window for rows that appear or disappear; shorter than the glide so the two motions read apart. */ +const ROW_FADE_MS = 100 +/** Glide window for rows that move to a new position. */ +const ROW_GLIDE_MS = 200 + +/** + * One-pass mute for whole-list layout changes: expanding or collapsing a + * group's hidden sessions, committing a drag, and switching the grouping, + * ordering, or archived filter reposition rows wholesale, so those passes + * apply instantly and animation stays reserved for pin jumps and archive + * fades. The DOM mutation auto-animate observes lands before the next frame + * renders, so the flag lifts there. + */ +let rowAnimationsMuted = false +export function muteNextRowAnimations(): void { + rowAnimationsMuted = true + requestAnimationFrame(() => { rowAnimationsMuted = false }) +} + +/** + * Auto-animate keyframes for the row containers: appearing and disappearing + * rows fade over {@link ROW_FADE_MS} while surviving rows glide to their new + * position over the longer {@link ROW_GLIDE_MS}. A muted pass + * ({@link muteNextRowAnimations}) settles every element immediately. + * @param el - the row element entering, leaving, or moving. + * @param action - what happened to the element in this layout pass. + * @param oldCoords - the element's box before the pass (moves only). + * @param newCoords - the element's box after the pass (moves only). + * @returns the keyframe effect auto-animate plays for the element. + */ +export const rowTransition: AutoAnimationPlugin = (el, action, oldCoords, newCoords) => { + if (rowAnimationsMuted) return new KeyframeEffect(el, [], { duration: 0 }) + if (action === 'add' || action === 'remove') { + return new KeyframeEffect(el, [ + { opacity: action === 'add' ? 0 : 1 }, + { opacity: action === 'add' ? 1 : 0 }, + ], { duration: ROW_FADE_MS, easing: 'ease-out' }) + } + const dx = (oldCoords?.left ?? 0) - (newCoords?.left ?? 0) + const dy = (oldCoords?.top ?? 0) - (newCoords?.top ?? 0) + return new KeyframeEffect(el, [ + { transform: `translate(${String(dx)}px, ${String(dy)}px)` }, + { transform: 'translate(0, 0)' }, + ], { duration: ROW_GLIDE_MS, easing: 'ease-out' }) +} + +/** + * Keyed auto-animate attachment for the row containers: rows glide to their + * new position when a pin reorders them and fade in or out when the archived + * filter, an archive, or an unarchive adds or removes them. Each key names one + * container; the factory hands out identity-stable callback refs so React + * attaches once per mounted element, and detach destroys the controller + * (auto-animate retains attached parents in a module-level strong set, so an + * undisposed controller would keep unmounted subtrees reachable across sidebar + * remounts). The library self-disables without ResizeObserver (jsdom) and + * under prefers-reduced-motion. + * @returns a factory yielding the stable callback ref for one container key. + */ +function useRowAnimator(): (key: string) => (el: HTMLElement | null) => void { + const controllers = useRef(new Map()) + const refs = useRef(new Map void>()) + return useCallback((key: string) => { + const existing = refs.current.get(key) + if (existing !== undefined) return existing + const ref = (el: HTMLElement | null): void => { + const previous = controllers.current.get(key) + if (previous !== undefined) { + previous.destroy?.() + disarmedRowControllers.delete(previous) + controllers.current.delete(key) + } + if (el === null) return + const controller = autoAnimate(el, rowTransition) + if (!rowAnimationsArmed) { + controller.disable() + disarmedRowControllers.add(controller) + } + controllers.current.set(key, controller) + } + refs.current.set(key, ref) + return ref + }, []) +} + +/** Grouping, ordering, and archived-filter menu; own open state so it resets with the wide chrome. */ +function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrderPick, onArchivedFilterPick, openSeq, anchorRef, t }: { 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 + /** Receives the trigger button element, so the archive hint can anchor under it. */ + anchorRef?: ((el: HTMLButtonElement | null) => void) | undefined t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) + // 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" @@ -133,12 +271,13 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { anchor={( )} @@ -146,11 +285,87 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { ) } +/** Hold before the archive callout dismisses itself. */ +const ARCHIVE_HINT_HOLD_MS = 3000 +/** 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 +/** Minimum gap between the archive callout and the viewport edges. */ +const ARCHIVE_HINT_EDGE_MARGIN = 12 + +/** + * Post-archive callout bubble under the view-options button, naming the filter + * that reveals archived rows. Fixed positioning escapes the section header's + * overflow clipping without a portal (the Tooltip primitive's approach). + * The bubble centers on the anchor but slides back inside the viewport when + * the edges refuse it; the arrow stays on the anchor center either way. + * Dismissed by its hold timer or any pointer press on the page. + */ +function ArchiveFilterHint({ anchor, text, onDone }: { + anchor: HTMLElement | null + text: string + onDone: () => void +}) { + useEffect(() => { + const timer = setTimeout(onDone, ARCHIVE_HINT_HOLD_MS) + // Capture phase: a press anywhere dismisses, even when the target stops + // propagation (menus, rows). + const dismiss = (): void => { onDone() } + document.addEventListener('pointerdown', dismiss, true) + return () => { + clearTimeout(timer) + document.removeEventListener('pointerdown', dismiss, true) + } + }, [onDone]) + const bubble = useRef(null) + const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + // Horizontal slide applied to keep the bubble inside the viewport; the + // arrow compensates by the same amount to keep pointing at the anchor. + const [dx, setDx] = useState(0) + useLayoutEffect(() => { + if (anchor === null) return + const measure = (): void => { + const rect = anchor.getBoundingClientRect() + setPos({ x: rect.left + rect.width / 2, y: rect.bottom + 8 }) + } + measure() + window.addEventListener('resize', measure) + return () => { window.removeEventListener('resize', measure) } + }, [anchor]) + useLayoutEffect(() => { + const el = bubble.current + if (el === null || pos === null) return + // Reset first so a larger viewport releases a previous slide. + el.style.left = `${String(pos.x)}px` + const r = el.getBoundingClientRect() + let slide = 0 + if (r.right > window.innerWidth - ARCHIVE_HINT_EDGE_MARGIN) { + slide = window.innerWidth - ARCHIVE_HINT_EDGE_MARGIN - r.right + } + if (r.left + slide < ARCHIVE_HINT_EDGE_MARGIN) slide = ARCHIVE_HINT_EDGE_MARGIN - r.left + el.style.left = `${String(pos.x + slide)}px` + setDx(slide) + }, [pos]) + if (pos === null) return null + return ( + + + {text} + + ) +} + /** 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 } @@ -190,8 +405,8 @@ 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. */ @@ -200,6 +415,10 @@ type SessionTreeProps = Pick< onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void /** Archive a session (row menu action; the row disappears on the state echo). */ 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 +428,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, + rowState, workspaceReady, usePanelInfo, - onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, onSessionUnarchive, onSessionPin, insertWorkspaceBefore, nestWorkspaces, groupExpansion, setGroupExpanded, setSessionOrder, home, t, @@ -226,6 +445,7 @@ function SessionTree({ ? undefined : owningGroupKey(workspaces, revealSessionId) const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) + const animator = useRowAnimator() // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) const sessionDropCommitted = useRef(false) @@ -262,11 +482,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)) { @@ -280,30 +500,52 @@ function SessionTree({ const group = groups.find(candidate => candidate.key === revealGroup) if (group === undefined || !group.expanded || !group.sessions.some(row => row.id === revealSessionId)) return if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return + muteNextRowAnimations() setExpandedSessionGroups(keys => keys.includes(revealGroup) ? keys : [...keys, revealGroup]) }, [groups, revealGroup, revealSessionId]) const now = Date.now() const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { if (sessionDropCommitted.current) return sessionDropCommitted.current = true + muteNextRowAnimations() setDrag(null) const group = groups.find(candidate => candidate.key === activeDrag.accountKey) if (group === undefined) return + if (over.id === activeDrag.sessionId) 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 currentBlank = group.sessions.find(node => node.blank)?.id + if (activeDrag.pinned) { + // Pinned rows render as one leading block in group order; reorder that + // sequence and write it back into the pinned rows' account slots, so + // every other member (hidden ones included) keeps its position. + const pinnedSeq = group.sessions.filter(node => node.pinned).map(node => node.id) + const nextSeq = pinnedSeq.filter(id => id !== activeDrag.sessionId) + const at = nextSeq.indexOf(over.id) + // Pin state can move under an in-flight drag (a Host pin event lands). + if (at === -1 || !pinnedSeq.includes(activeDrag.sessionId)) return + nextSeq.splice(over.half === 'before' ? at : at + 1, 0, activeDrag.sessionId) + if (nextSeq.every((id, index) => id === pinnedSeq[index])) return + const pinnedSlots = new Set(pinnedSeq) + let cursor = 0 + // The cast is sound: nextSeq is a permutation of the pinned slots. + const nextOrder = accountSessionIds.map(id => + pinnedSlots.has(id) ? nextSeq[cursor++] as SessionId : id) + setSessionOrder(activeDrag.accountKey, pinCurrentBlank(nextOrder, currentBlank)) + return + } 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) { @@ -329,7 +571,6 @@ function SessionTree({ }) 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)) } const commitWorkspaceDrag = ( @@ -338,6 +579,7 @@ function SessionTree({ ): void => { if (workspaceDropCommitted.current) return workspaceDropCommitted.current = true + muteNextRowAnimations() setWorkspaceDrag(null) const owner = parents.get(activeDrag.workspaceId) const siblings = workspaces.filter(workspace => parents.get(workspace.workspaceId) === owner) @@ -410,6 +652,7 @@ function SessionTree({ // (WorkspaceBrowser.module.css).
{group.expanded && children.length > 0 && ( -
+
{children.map(child => renderGroup(child, depth + 1))}
)} @@ -484,16 +727,18 @@ function SessionTree({ ? group.sessions : collapsed.rows ).map((node) => { - // Session drag never leaves its browser-local account. + // 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 +767,8 @@ function SessionTree({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} + onUnarchive={onSessionUnarchive} + onPin={onSessionPin} onReveal={node.id === revealSessionId && group.key === revealGroup ? () => { onSessionRevealed(node.id) } : undefined} @@ -535,7 +782,7 @@ function SessionTree({ type="button" className={css.sessionOverflowButton} aria-expanded={sessionsExpanded} - onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }} + onClick={() => { muteNextRowAnimations(); setExpandedSessionGroups(keys => toggled(keys, group.key)) }} > {sessionsExpanded ? t('sessions.collapse') @@ -553,6 +800,7 @@ function SessionTree({ className={clsx(css.list, workspaceDropAtListStart && css.listTopDropActive)} role="tree" aria-label={t('section.sessions')} + ref={animator('__list__')} > {groups.length === 0 && (
{t('empty.none')}
@@ -566,7 +814,8 @@ function SessionTree({ /** The flat "In one list" body: every session is one draggable top-level row. */ function FlatList({ - list, sessionIds, useSessionStatus, open, forkSession, onSessionRename, onSessionArchive, + list, sessionIds, rowState, useSessionStatus, open, forkSession, onSessionRename, onSessionArchive, + onSessionUnarchive, onSessionPin, usePanelInfo, setSessionOrder, revealSessionId, onSessionRevealed, t, }: Pick< @@ -576,10 +825,13 @@ function FlatList({ | 'forkSession' | 'onSessionRename' | 'onSessionArchive' + | 'onSessionUnarchive' + | 'onSessionPin' | 'usePanelInfo' | 'setSessionOrder' | 'revealSessionId' | 'onSessionRevealed' + | 'rowState' | 't' > & { list: SessionListState @@ -588,8 +840,8 @@ function FlatList({ const panelActive = usePanelInfo(info => info.activePanelId !== null) const statuses = useSessionStatus(s => s) const rows = useMemo( - () => deriveFlat(list, sessionIds, statuses), - [list, sessionIds, statuses], + () => deriveFlat(list, sessionIds, rowState, statuses), + [list, sessionIds, rowState, statuses], ) const [drag, setDrag] = useState(null) const dropCommitted = useRef(false) @@ -615,14 +867,17 @@ function FlatList({ setSessionOrder(FLAT_SESSION_ORDER_KEY, pinCurrentBlank(nextOrder, currentBlank)) } const now = Date.now() + const animator = useRowAnimator() return (
-
+
{rows.length === 0 && (
{t('empty.none')}
)} {rows.map((node) => { - const active = drag !== null + // Pinned rows reorder only within their leading pinned block; the + // wholesale rendered-order write then keeps that block contiguous. + const active = drag !== null && drag.pinned === node.pinned const normalizeHalf = (half: 'before' | 'after'): 'before' | 'after' => node.blank ? 'after' : half return ( @@ -635,6 +890,8 @@ function FlatList({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} + onUnarchive={onSessionUnarchive} + onPin={onSessionPin} onReveal={node.id === revealSessionId ? () => { onSessionRevealed(node.id) } : undefined} @@ -642,7 +899,7 @@ function FlatList({ drag={{ start: () => { dropCommitted.current = false - setDrag({ accountKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null }) + setDrag({ accountKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, pinned: node.pinned, over: null }) }, active, marker: active && drag.over?.id === node.id ? drag.over.half : null, @@ -682,8 +939,10 @@ function SearchResults({ useSessions, useSessionStatus, open, + onUnarchive, workspaces, archivedSessionIds, + archivedFilter, query, remote, resultLimit, @@ -692,6 +951,10 @@ function SearchResults({ }: Pick & { workspaces: readonly WorkspaceView[] archivedSessionIds: readonly SessionNode['id'][] + /** Search matches follow the archived filter selected for the list. */ + archivedFilter: ArchivedFilter + /** Unarchive an archived result row in place. */ + onUnarchive: (id: SessionNode['id']) => void query: string remote: RemoteSearchState resultLimit: number @@ -708,11 +971,12 @@ function SearchResults({ workspaces, query, archivedSessionIds, + archivedFilter, statuses, currentRemote, resultLimit, ), - [list, workspaces, query, archivedSessionIds, statuses, currentRemote, resultLimit], + [list, workspaces, query, archivedSessionIds, archivedFilter, statuses, currentRemote, resultLimit], ) const pending = currentRemote.status === 'loading' const failed = currentRemote.status === 'error' @@ -730,16 +994,24 @@ function SearchResults({ result={result} currentId={currentId} onOpen={open} + onUnarchive={onUnarchive} t={t} /> ))}
{pending && ( -
{t('search.pending')}
- )} - {failed && ( -
- {t('search.unavailable')} + /* Two skeleton rows on an empty list, one when local matches already + show and only the content hits are outstanding. */ +
+ {(results.items.length === 0 ? [0, 1] : [0]).map(i => ( + + ))}
)} {!pending && results.items.length === 0 && ( @@ -778,6 +1050,9 @@ export function WorkspaceBrowser({ deleteWorkspace, insertWorkspaceBefore, archiveSession, + unarchiveSession, + pinSession, + unpinSession, createWorkspace, searchSessions, searchResultLimit, @@ -793,13 +1068,43 @@ export function WorkspaceBrowser({ const workspacePhase = useWorkspaces(state => state.phase) const workspaceStreamState = useWorkspaces(state => state.state) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) + const pinnedSessions = useWorkspaces(state => state.pinnedSessions) // Live occupancy of this surface's directory-flow hole (the same source the // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) const orderBy = useStore(s => s.orderBy) + // Persisted view blobs written before the archived filter existed rehydrate + // without the field; they read as the default hide-archived view. + const archivedFilter = useStore(s => s.archivedFilter ?? 'default') const groupExpansion = useStore(s => s.groupExpansion) const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount) + const archiveHintSeen = useStore(s => s.archiveHintSeen) + // Archive callout under the view-options button; the seq keys remounts so + // consecutive archives restart the hold. + const viewOptionsButton = useRef(null) + const [archiveHintSeq, setArchiveHintSeq] = useState(0) + // Each bump opens the view-options menu (the archive toast's filter action). + const [viewOptionsOpenSeq, setViewOptionsOpenSeq] = useState(0) + // One transient banner at a time; the seq keys remounts so a repeat message restarts its hold. + const [toast, setToast] = useState<{ + text: string + seq: number + tone: 'warning' | 'success' + actions?: readonly { label: string; onClick: () => void }[] + } | null>(null) + const showToast = (text: string): void => { + setToast(current => ({ text, seq: (current?.seq ?? 0) + 1, tone: 'warning' })) + } + // Archived sessions are not openable: the row stays visible under the + // filter but a click explains instead of navigating. + const guardedOpen = (sessionId: SessionId): void => { + if (archivedSessionIds.includes(sessionId)) { + showToast(t('toast.archivedNotOpenable')) + return + } + open(sessionId) + } const workspaceReady = workspacePhase === 'ready' && workspaceStreamState !== 'loading' const mainSessionId = Object.values(list.byId) .find(session => (session.retainedBy.mainView ?? 0) > 0)?.id @@ -810,14 +1115,24 @@ export function WorkspaceBrowser({ const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds)) return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id)) }, [list, workspaces]) + const rowState = useMemo( + () => ({ pinnedSessions, archivedSessionIds, archivedFilter }), + [archivedSessionIds, pinnedSessions, archivedFilter], + ) + // Pinned rows rank by the later of their pin instant and update recency, so + // a newly pinned or newly updated pinned row leads the pinned block. + const pinnedAtBySession = useMemo( + () => new Map(pinnedSessions.map(entry => [entry.sessionId, entry.pinnedAt])), + [pinnedSessions], + ) const flatMemberIds = useMemo( - () => visibleSessionIds(list, archivedSessionIds), - [archivedSessionIds, list], + () => visibleSessionIds(list, archivedSessionIds, archivedFilter), + [archivedSessionIds, list, archivedFilter], ) const orderedWorkspaces = useMemo(() => workspaces.map((workspace) => { const memberIds = workspace.sessionIds const baseOrder = orderBy === 'updated' - ? orderByRecency(memberIds, list.byId) + ? orderByRecency(memberIds, list.byId, pinnedAtBySession) : reconcileManualOrder(memberIds, sessionOrderByAccount[workspace.workspaceId], list.byId) return { ...workspace, @@ -826,25 +1141,25 @@ export function WorkspaceBrowser({ currentBlank !== undefined && memberIds.includes(currentBlank) ? currentBlank : undefined, ), } - }), [currentBlank, list.byId, orderBy, sessionOrderByAccount, workspaces]) + }), [currentBlank, list.byId, orderBy, pinnedAtBySession, sessionOrderByAccount, workspaces]) const orderedUngroupedSessionIds = useMemo(() => { const baseOrder = orderBy === 'updated' - ? orderByRecency(ungroupedMemberIds, list.byId) + ? orderByRecency(ungroupedMemberIds, list.byId, pinnedAtBySession) : reconcileManualOrder(ungroupedMemberIds, sessionOrderByAccount[UNGROUPED_KEY], list.byId) return pinCurrentBlank( baseOrder, currentBlank !== undefined && ungroupedMemberIds.includes(currentBlank) ? currentBlank : undefined, ) - }, [currentBlank, list.byId, orderBy, sessionOrderByAccount, ungroupedMemberIds]) + }, [currentBlank, list.byId, orderBy, pinnedAtBySession, sessionOrderByAccount, ungroupedMemberIds]) const orderedFlatSessionIds = useMemo(() => { const baseOrder = orderBy === 'updated' - ? orderByRecency(flatMemberIds, list.byId) + ? orderByRecency(flatMemberIds, list.byId, pinnedAtBySession) : reconcileManualOrder(flatMemberIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY], list.byId) return pinCurrentBlank( baseOrder, currentBlank !== undefined && flatMemberIds.includes(currentBlank) ? currentBlank : undefined, ) - }, [currentBlank, flatMemberIds, list.byId, orderBy, sessionOrderByAccount]) + }, [currentBlank, flatMemberIds, list.byId, orderBy, pinnedAtBySession, sessionOrderByAccount]) const activeSessionOrders = useMemo>>(() => Object.fromEntries([ ...orderedWorkspaces.map(workspace => [workspace.workspaceId, workspace.sessionIds] as const), [UNGROUPED_KEY, orderedUngroupedSessionIds] as const, @@ -919,6 +1234,10 @@ export function WorkspaceBrowser({ const composingRef = useRef(false) const openSearchResult = (sessionId: SessionId): void => { + if (archivedSessionIds.includes(sessionId)) { + showToast(t('toast.archivedNotOpenable')) + return + } setRevealSessionId(sessionId) setQuery('') setSearchExpanded(false) @@ -1069,10 +1388,55 @@ export function WorkspaceBrowser({ // archive-set echo lands. Failures are non-fatal console diagnostics, the // same posture as reorder rejections. const onSessionArchive = (sessionId: SessionNode['id']) => { - archiveSession(sessionId).catch((reason: unknown) => { + archiveSession(sessionId).then(() => { + // The first archive teaches the filter with the anchored callout; every + // later archive confirms with an actionable toast instead (undo the + // archive, or open the view-options menu on the filter items). + if (!archiveHintSeen) { + actions.markArchiveHintSeen() + setArchiveHintSeq(seq => seq + 1) + return + } + setToast(current => ({ + text: t('toast.archived'), + seq: (current?.seq ?? 0) + 1, + tone: 'success', + actions: [ + { label: t('toast.archivedUndo'), onClick: () => { setToast(null); onSessionUnarchive(sessionId) } }, + { + prefix: t('toast.archivedOr'), + label: t('toast.archivedFilter'), + onClick: () => { setToast(null); setViewOptionsOpenSeq(seq => seq + 1) }, + }, + ], + })) + }).catch((reason: unknown) => { console.warn('session archive rejected:', reason) }) } + const onSessionUnarchive = (sessionId: SessionNode['id']) => { + unarchiveSession(sessionId).catch((reason: unknown) => { + console.warn('session unarchive rejected:', reason) + }) + } + // Pin failures surface as a toast: unlike archive, nothing else on the + // surface moves, so a silent failure would read as a dead menu action. + const onSessionPin = (sessionId: SessionNode['id'], pin: boolean) => { + (pin ? pinSession(sessionId) : unpinSession(sessionId)).then(() => { + // In manual mode a fresh pin also fronts the row's manual slot, so the + // pinned block starts in pin order; drags within the block still adjust + // it. The slot is not restored on unpin: the row stays at the top. + if (!pin || orderBy !== 'manual') return + actions.syncSessionOrders(Object.fromEntries( + [owningGroupKey(workspaces, sessionId), FLAT_SESSION_ORDER_KEY].map((key) => { + const saved = sessionOrderByAccount[key] ?? [] + return [key, [sessionId, ...saved.filter(id => id !== sessionId)]] + }), + )) + }).catch(() => { + showToast(t(pin ? 'toast.pinFailed' : 'toast.unpinFailed')) + }) + } // Delete dialog is separate from the row so a successful removal can // unmount that row without tearing down the in-flight confirmation state. @@ -1179,8 +1543,12 @@ export function WorkspaceBrowser({ { actions.setGroupBy(mode) }} - onOrderPick={(mode) => { actions.setOrderBy(mode, activeSessionOrders) }} + archivedFilter={archivedFilter} + onGroupPick={(mode) => { muteNextRowAnimations(); actions.setGroupBy(mode) }} + onOrderPick={(mode) => { muteNextRowAnimations(); actions.setOrderBy(mode, activeSessionOrders) }} + onArchivedFilterPick={(filter) => { muteNextRowAnimations(); actions.setArchivedFilter(filter) }} + openSeq={viewOptionsOpenSeq} + anchorRef={(el) => { viewOptionsButton.current = el }} t={t} /> )} @@ -1250,8 +1618,10 @@ export function WorkspaceBrowser({ useSessions={useSessions} useSessionStatus={useSessionStatus} open={openSearchResult} + onUnarchive={onSessionUnarchive} workspaces={workspaces} archivedSessionIds={archivedSessionIds} + archivedFilter={archivedFilter} query={normalizedQuery} remote={remoteSearch} resultLimit={searchResultLimit} @@ -1264,9 +1634,11 @@ export function WorkspaceBrowser({ usePanelInfo={usePanelInfo} list={list} sessionIds={orderedFlatSessionIds} + rowState={rowState} useSessionStatus={useSessionStatus} - open={open} forkSession={forkSession} + open={guardedOpen} forkSession={forkSession} onSessionRename={onSessionRename} onSessionArchive={onSessionArchive} + onSessionUnarchive={onSessionUnarchive} onSessionPin={onSessionPin} setSessionOrder={saveSessionOrder} revealSessionId={revealSessionId} onSessionRevealed={acknowledgeSessionReveal} @@ -1280,6 +1652,8 @@ export function WorkspaceBrowser({ useSessionStatus={useSessionStatus} onSessionRename={onSessionRename} onSessionArchive={onSessionArchive} + onSessionUnarchive={onSessionUnarchive} + onSessionPin={onSessionPin} forkSession={forkSession} workspaces={orderedWorkspaces} ungroupedSessionIds={orderedUngroupedSessionIds} @@ -1288,9 +1662,9 @@ export function WorkspaceBrowser({ groupExpansion={groupExpansion} setGroupExpanded={actions.setGroupExpanded} setSessionOrder={saveSessionOrder} - archivedSessionIds={archivedSessionIds} + rowState={rowState} startSession={startSession} - open={open} + open={guardedOpen} insertWorkspaceBefore={insertWorkspaceBefore} revealSessionId={revealSessionId} onSessionRevealed={acknowledgeSessionReveal} @@ -1400,6 +1774,25 @@ export function WorkspaceBrowser({ {deleting &&
{t('delete.pending')}
} {deleteError !== null &&
{deleteError}
} + {toast !== null && ( + }} + {...toast.actions === undefined ? {} : { actions: toast.actions, holdMs: ARCHIVE_TOAST_HOLD_MS }} + onDone={() => { setToast(null) }} + /> + )} + {archiveHintSeq > 0 && ( + { setArchiveHintSeq(0) }} + /> + )}
) } diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index 10fa80239f..bd1ac63d87 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -6,6 +6,7 @@ * share from the return type. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { ArchivedFilter } from './tree.ts' /** Browser-local order account for the hierarchy-free flat Session list. */ export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__' @@ -23,6 +24,14 @@ type WorkspaceViewState = { groupExpansion: Record /** Saved manual order per Workspace group plus the browser-local flat-list account. */ sessionOrderByAccount: Record + /** + * Archived-row visibility, kept across reloads like the other view options. + * Pre-filter v5 blobs lack the field; the browser reads it with a 'default' + * fallback at the rehydration boundary. + */ + archivedFilter: ArchivedFilter + /** The one-time hint pointing at the view menu after the first archive has been shown. */ + archiveHintSeen: boolean } /** @@ -48,6 +57,8 @@ type WorkspaceViewActions = { order: readonly string[], initialOrders: Readonly>, ) => void + setArchivedFilter: (draft: WorkspaceViewState, filter: ArchivedFilter) => void + markArchiveHintSeen: (draft: WorkspaceViewState) => void } /** Copy read-only projections into the persisted mutable store representation. */ @@ -68,6 +79,8 @@ export function createWorkspaceViewStore(): EngineStoreHandle { d.archivedFilter = filter }, + markArchiveHintSeen: (d) => { d.archiveHintSeen = true }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index f96157a83e..4dc4c8bbb0 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -6,12 +6,13 @@ import { type SessionListState, type SessionSearchResultItem, type SessionSummary, } from '@deepseek-ai/dsh-api-session-controller/client' -import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { WorkspaceId, WorkspacePinnedSession, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionStatusSnapshot, } from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-schedule/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { assertNever } from '@deepseek-ai/dsh-util-values' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import { indexSubagentDescendants, type SubagentDescendantSummary, @@ -59,6 +60,10 @@ export interface SessionNode { completed: boolean /** The current list projection contains at least one active Schedule record. */ hasActiveSchedule: boolean + /** In the registry-global pin set: leads its section, reorderable only among pinned rows. */ + pinned: boolean + /** In the registry-global archive set: shown grayed in place and not openable. */ + archived: boolean updatedAt: number } @@ -98,6 +103,8 @@ export interface SearchResultNode { completed: boolean /** The current list projection contains at least one active Schedule record. */ hasActiveSchedule: boolean + /** In the registry-global archive set: shown grayed and not openable. */ + archived: boolean snippet?: string } @@ -139,18 +146,23 @@ export function workspaceLabel(cwd: string | undefined): string { * Project known account members by current Session recency. * @param sessionIds - authoritative account membership. * @param summaries - current Session summaries; members without a summary are omitted until it arrives. + * @param pinnedAt - pin instants by Session; a pinned row ranks by the later of its pin instant and update recency. * @returns known members newest first, with Session identity as the deterministic tie-break. */ export function orderByRecency( sessionIds: readonly SessionId[], summaries: SessionListState['byId'], + pinnedAt?: ReadonlyMap, ): SessionId[] { return sessionIds.flatMap((id) => { const summary = summaries[id] - return summary === undefined ? [] : [{ id, updatedAt: summary.updatedAt }] + if (summary === undefined) return [] + const pin = pinnedAt?.get(id) + const rank = pin === undefined ? summary.updatedAt : Math.max(pin, summary.updatedAt) + return [{ id, rank }] }) .sort((a, b) => { - if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt + if (a.rank !== b.rank) return b.rank - a.rank return a.id < b.id ? -1 : 1 }) .map(member => member.id) @@ -199,16 +211,69 @@ export function pinCurrentBlank( return [currentBlank, ...order.filter(id => id !== currentBlank)] } +/** + * Archived-row visibility choice: the default hides archived rows, `show` + * mixes them into their kept slots, and `only` restricts the view (and + * search) to archived rows. + */ +export type ArchivedFilter = 'default' | 'show' | 'only' + /** * Ordinary sessions are visible; among blank sessions, only the current one * is visible. Subagent children use their parent header catalog; archived - * sessions are visible nowhere, while their accounting slots remain so - * unarchiving restores position. + * sessions follow the archived filter, while their accounting slots remain + * either way so unarchiving restores position. */ -function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet): boolean { - return session.origin !== 'subagent' - && !archived.has(session.id) - && (!session.blank || session.id === current) +function sessionVisible( + session: SessionSummary, + current: SessionId | undefined, + archived: ReadonlySet, + archivedFilter: ArchivedFilter, +): boolean { + if (session.origin === 'subagent') return false + if (session.blank && session.id !== current) return false + switch (archivedFilter) { + case 'default': + return !archived.has(session.id) + case 'show': + return true + case 'only': + return archived.has(session.id) + /* v8 ignore next 2 -- closed-union backstop; only reached if the filter is forged */ + default: + return assertNever(archivedFilter) + } +} + +/** Registry-global row state consumed by every tree derivation. */ +export interface SessionRowState { + /** Pin set with pin instants; pinned rows lead their section in the local order. */ + pinnedSessions: readonly WorkspacePinnedSession[] + /** Archive set; members keep their slots and show grayed while visible. */ + archivedSessionIds: readonly SessionId[] + /** Archived-row visibility choice applied to lists and search alike. */ + archivedFilter: ArchivedFilter +} + +/** + * Move pinned rows to the front without disturbing relative order on either + * side of the partition. Pinned rows keep the caller order among themselves — + * recency or the persisted manual order, so pinned-to-pinned drags hold — and + * archived rows keep their caller slots: the order never absorbs pin state, + * so unpinning restores the row's kept position. + */ +function sectionMembers( + members: readonly SessionSummary[], + pinned: ReadonlySet, + archived: ReadonlySet, +): SessionSummary[] { + const leading: SessionSummary[] = [] + const rest: SessionSummary[] = [] + for (const member of members) { + if (!archived.has(member.id) && pinned.has(member.id)) leading.push(member) + else rest.push(member) + } + return [...leading, ...rest] } /** @@ -264,6 +329,7 @@ function groupByWorkspace( list: SessionListState, workspaces: readonly WorkspaceView[], archived: ReadonlySet, + archivedFilter: ArchivedFilter, ungroupedOrder: readonly string[] | undefined, ): Group[] { const current = mainSessionId(list) @@ -275,7 +341,7 @@ function groupByWorkspace( const summary = list.byId[id] if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands accounted.add(id) - if (!sessionVisible(summary, current, archived)) continue + if (!sessionVisible(summary, current, archived, archivedFilter)) continue members.push(summary) } groups.push(buildGroup( @@ -286,7 +352,7 @@ function groupByWorkspace( const stray = list.ids .map(id => list.byId[id]) .filter((s): s is SessionSummary => - s !== undefined && !accounted.has(s.id) && sessionVisible(s, current, archived)) + s !== undefined && !accounted.has(s.id) && sessionVisible(s, current, archived, archivedFilter)) if (stray.length > 0) { groups.push(buildGroup( UNGROUPED_KEY, @@ -316,6 +382,8 @@ function sessionNode( s: SessionSummary, descendants: ReadonlyMap, statuses: SessionStatuses, + pinned: ReadonlySet, + archived: ReadonlySet, ): SessionNode { const status = statuses.get(s.id) const pendingInteraction = visiblePendingKind(status?.pendingInteraction?.kind) @@ -327,6 +395,8 @@ function sessionNode( runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0, completed: status?.completionUnread === true, hasActiveSchedule: hasActiveSchedule(s), + pinned: !archived.has(s.id) && pinned.has(s.id), + archived: archived.has(s.id), updatedAt: s.updatedAt, ...(pendingInteraction === undefined ? {} : { pendingInteraction }), } @@ -335,14 +405,14 @@ function sessionNode( /** * Derive the workspace browser groups with every session as a top-level row. * - * Every group shows; sessions populate under expanded groups in the selected - * local order. Blank sessions are excluded except for the selected - * provisional New Session row; archived sessions are excluded everywhere. - * Content search lives outside this derivation - * (see {@link deriveSearchResults}). + * Every group shows; sessions populate under expanded groups with pinned rows + * leading in the selected local order. Blank sessions are + * excluded except for the selected provisional New Session row; archived + * sessions keep their slots and appear per the archived filter. Content + * search lives outside this derivation (see {@link deriveSearchResults}). * @param list - sessions list snapshot (`mainView` retention feeds containsCurrent). * @param workspaces - real Workspaces in Host group order with caller-projected Session order. - * @param archivedSessionIds - registry-global archive set. + * @param rowState - registry-global pin and archive sets plus the archived filter. * @param statuses - unified UI status by Session. * @param view - local expansion arrays. * @returns group sections in render order. @@ -350,11 +420,12 @@ function sessionNode( export function deriveGroups( list: SessionListState, workspaces: readonly WorkspaceView[], - archivedSessionIds: readonly SessionId[], + rowState: SessionRowState, statuses: SessionStatuses, view: TreeView, ): GroupNode[] { - const archived = new Set(archivedSessionIds) + const archived = new Set(rowState.archivedSessionIds) + const pinned = new Set(rowState.pinnedSessions.map(entry => entry.sessionId)) const expandedGroups = new Set(view.expandedGroups) const descendants = indexSubagentDescendants(list.byId) const current = mainSessionId(list) @@ -362,7 +433,7 @@ export function deriveGroups( ? undefined : owningGroupKey(workspaces, current) const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) { + for (const g of groupByWorkspace(list, workspaces, archived, rowState.archivedFilter, view.ungroupedOrder)) { const expanded = expandedGroups.has(g.key) groups.push({ key: g.key, @@ -374,7 +445,8 @@ export function deriveGroups( expanded, containsCurrent: g.key === currentGroup, sessions: expanded - ? g.sessions.map(session => sessionNode(session, descendants, statuses)) + ? sectionMembers(g.sessions, pinned, archived) + .map(session => sessionNode(session, descendants, statuses, pinned, archived)) : [], }) } @@ -385,35 +457,42 @@ export function deriveGroups( * Select flat-list members without deriving row presentation or ordering. * @param list - sessions list snapshot. * @param archivedSessionIds - registry-global archive set. + * @param archivedFilter - archived-row visibility choice. * @returns known visible Session ids in list order, including ordinary forks and only the current blank. */ export function visibleSessionIds( list: SessionListState, archivedSessionIds: readonly SessionId[], + archivedFilter: ArchivedFilter, ): SessionId[] { const archived = new Set(archivedSessionIds) const current = mainSessionId(list) return list.ids.filter((id) => { const s = list.byId[id] - return s !== undefined && sessionVisible(s, current, archived) + return s !== undefined && sessionVisible(s, current, archived, archivedFilter) }) } /** - * Derive flat rows from the browser's ordered visible Session ids. + * Derive flat rows from the browser's ordered visible Session ids, with + * pinned rows fronted ahead of the supplied order. * @param list - sessions list snapshot used to select the ids. * @param sessionIds - known visible members in render order, including any pinned blank. + * @param rowState - registry-global pin and archive sets plus the archived filter. * @param statuses - unified UI status by Session. - * @returns flat rows in the supplied order with current status indicators. + * @returns flat rows in sectioned order with current status indicators. */ export function deriveFlat( list: SessionListState, sessionIds: readonly SessionId[], + rowState: SessionRowState, statuses: SessionStatuses, ): SessionNode[] { + const archived = new Set(rowState.archivedSessionIds) + const pinned = new Set(rowState.pinnedSessions.map(entry => entry.sessionId)) const descendants = indexSubagentDescendants(list.byId) - return sessionIds - .map(id => sessionNode(list.byId[id] as SessionSummary, descendants, statuses)) + return sectionMembers(sessionIds.map(id => list.byId[id] as SessionSummary), pinned, archived) + .map(session => sessionNode(session, descendants, statuses, pinned, archived)) } /** @@ -423,7 +502,8 @@ export function deriveFlat( * @param list - session metadata authority. * @param workspaces - Workspace membership and display labels. * @param query - caller text; surrounding whitespace is ignored. - * @param archivedSessionIds - registry-global archive set (members never match). + * @param archivedSessionIds - registry-global archive set (members match per the archived filter). + * @param archivedFilter - archived-row visibility choice; search follows it. * @param statuses - unified UI status by Session. * @param content - ranked Host content-search page. * @param limit - protocol-owned maximum merged row count. @@ -434,6 +514,7 @@ export function deriveSearchResults( workspaces: readonly WorkspaceView[], query: string, archivedSessionIds: readonly SessionId[], + archivedFilter: ArchivedFilter, statuses: SessionStatuses, content: { items: readonly SessionSearchResultItem[]; hasMore: boolean }, limit: number, @@ -462,7 +543,7 @@ export function deriveSearchResults( const summary = list.byId[id] // Blank placeholders never match a query (their canonical title displays // localized, so matching it would tie search to one language). - if (summary === undefined || summary.blank || !sessionVisible(summary, current, archived)) continue + if (summary === undefined || summary.blank || !sessionVisible(summary, current, archived, archivedFilter)) continue if ( sessionTitle(summary).toLowerCase().includes(q) || labelOf(summary).toLowerCase().includes(q) @@ -484,7 +565,7 @@ export function deriveSearchResults( for (const summary of orderedLocal) include(summary) for (const item of content.items) { const summary = list.byId[item.sessionId] - if (summary !== undefined && !summary.blank && sessionVisible(summary, current, archived)) include(summary) + if (summary !== undefined && !summary.blank && sessionVisible(summary, current, archived, archivedFilter)) include(summary) } return { @@ -503,6 +584,7 @@ export function deriveSearchResults( : { pendingInteraction }), completed: status?.completionUnread === true, hasActiveSchedule: hasActiveSchedule(summary), + archived: archived.has(summary.id), ...match === undefined ? {} : { snippet: match.snippet }, } }), diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 9e53d52b1b..1772e6be6d 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -48,7 +48,7 @@ async function bench() { ctx.provide('workspaces', { list: { getSnapshot: () => ({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }), subscribe, }, diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 57308820e2..e8b5439d95 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -62,15 +62,15 @@ describe('workspace browser rows', () => { it('omits only an empty leading status slot in the hierarchy-free flat list', () => { const idle: SessionNode = { id: sid('flat'), title: 'Flat Session', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } const view = render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} flat t={t} />) const title = screen.getByText('Flat Session') expect(title.previousElementSibling).toBeNull() view.rerender() + onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} flat t={t} />) expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy() }) @@ -84,9 +84,10 @@ describe('workspace browser rows', () => { runningSubagentCount: 0, completed: false, hasActiveSchedule: false, + archived: false, snippet: 'matching message excerpt', } - render() + render() const row = screen.getByRole('treeitem') expect(row.getAttribute('aria-selected')).toBe('true') expect(screen.getByText('Workspace context')).toBeTruthy() @@ -102,9 +103,9 @@ describe('workspace browser rows', () => { const onOpen = vi.fn() const result: SearchResultNode = { id: sid('scheduled-result'), title: 'Scheduled result', workspace: 'Project', - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: true, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: true, archived: false, } - render() + render() const row = screen.getByRole('treeitem') const title = screen.getByText('Scheduled result') @@ -126,9 +127,9 @@ describe('workspace browser rows', () => { const result: SearchResultNode = { id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project', pendingInteraction, running: true, runningSubagentCount: 0, completed: false, - hasActiveSchedule: false, + hasActiveSchedule: false, archived: false, } - render() + render() const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="warning"]')).toBeTruthy() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() @@ -155,12 +156,12 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { id: sid('session'), title: 'Session', blank: false, running: true, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } const onOpen = vi.fn() render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />, ) const row = screen.getByRole('treeitem') @@ -225,11 +226,11 @@ describe('workspace browser rows', () => { const onOpen = vi.fn() const node: SessionNode = { id: sid('scheduled-session'), title: 'Scheduled Session', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: true, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: true, updatedAt: 0, pinned: false, archived: false, } const view = render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />, ) const assertIndicator = (): HTMLElement => { @@ -248,7 +249,7 @@ describe('workspace browser rows', () => { view.rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} flat t={t} />, ) assertIndicator() }) @@ -258,10 +259,10 @@ describe('workspace browser rows', () => { , ) const stateDot = (view: ReturnType) => @@ -290,10 +291,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('owner'), title: 'Delegating', blank: false, running: false, - runningSubagentCount: 2, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 2, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull() expect(screen.getByText('2 个子代理运行中')).toBeTruthy() @@ -312,10 +313,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('owner'), title: 'Delegating', blank: false, running: true, - runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const row = screen.getByRole('treeitem') expect(row.querySelectorAll('[data-state="ongoing"]')).toHaveLength(1) expect(screen.getByText('进行中')).toBeTruthy() @@ -333,10 +334,10 @@ describe('workspace browser rows', () => { it('keeps child activity as a secondary status while user attention is primary', () => { const node: SessionNode = { id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question', - running: false, runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, + running: false, runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="warning"]')).not.toBeNull() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() @@ -348,9 +349,9 @@ describe('workspace browser rows', () => { render() expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull() }) @@ -480,10 +481,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s-blank'), title: 'ignored', blank: true, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) // The placeholder has no content yet: no row verbs, no "now" stamp. expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull() expect(screen.queryByText('刚刚')).toBeNull() @@ -507,10 +508,10 @@ describe('workspace browser rows', () => { const onArchive = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={onRename} onFork={onFork} onArchive={onArchive} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) expect(onOpen).not.toHaveBeenCalled() // Archive is not destructive (log and accounting slot remain): no danger styling. @@ -541,10 +542,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Hovered', blank: false, running: true, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) @@ -573,10 +574,10 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid(pendingInteraction), title: 'Needs input', blank: false, pendingInteraction, running: true, runningSubagentCount: 0, completed: false, - hasActiveSchedule: false, updatedAt: 0, + hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } const view = render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) const row = screen.getByRole('treeitem') const title = screen.getByText('Needs input') expect(row.querySelector('[data-state="warning"]')).toBeTruthy() @@ -587,7 +588,7 @@ describe('workspace browser rows', () => { expect(row.textContent).not.toContain('刚刚') view.rerender() + onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy() fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) @@ -623,10 +624,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Quiet', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('空闲').parentElement?.querySelector('[data-state="idle"]')).not.toBeNull() @@ -641,10 +642,10 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Done', blank: false, running: false, - runningSubagentCount: 0, completed: true, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: true, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) // Row's visually-hidden reminder label plus the hover card's status line. @@ -654,15 +655,41 @@ describe('workspace browser rows', () => { } }) + it('archived hover card replaces the resting status with the archived line but keeps live activity', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Stored', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: true, + } + const { rerender } = render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + // Row indicator label plus the hover card's archived line; the idle + // resting status would restate the same inactivity and stays out. + expect(screen.getAllByText('已归档')).toHaveLength(1) + expect(screen.queryByText('空闲')).toBeNull() + + // Live activity on an archived session still shows beside the line. + rerender() + expect(screen.getAllByText('进行中').length).toBeGreaterThan(0) + expect(screen.getAllByText('已归档')).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { id: sid('s1'), title: 'Drag me', blank: false, running: false, - runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } const inactive = dragProps() const { rerender } = render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} drag={inactive} t={t} />, ) const row = screen.getByRole('treeitem') stubRect(row) @@ -680,7 +707,7 @@ describe('workspace browser rows', () => { const active = dragProps({ active: true, marker: 'before' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} drag={active} t={t} />, ) stubRect(screen.getByRole('treeitem')) // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). @@ -694,8 +721,139 @@ describe('workspace browser rows', () => { const after = dragProps({ active: true, marker: 'after' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} drag={after} t={t} />, ) expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) }) + + it('marks a pinned row, keeps it draggable, and unpins through the row menu', () => { + const onPin = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Pinned', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: true, archived: false, + } + render() + const row = screen.getByRole('treeitem') + // Reorderable within the pinned block; the browser gates the targets. + expect(row.getAttribute('draggable')).toBe('true') + expect(screen.getByRole('img', { name: '已置顶' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '会话“Pinned”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '取消置顶' })) + expect(onPin).toHaveBeenCalledWith(node.id, false) + }) + + it('pins an unpinned row through the row menu', () => { + const onPin = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Plain', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + render() + expect(screen.queryByRole('img', { name: '已置顶' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '会话“Plain”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' })) + expect(onPin).toHaveBeenCalledWith(node.id, true) + }) + + it('marks an archived row, blocks dragging it, and swaps its menu to rename/fork/unarchive', () => { + const onUnarchive = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Stored', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: true, + } + render() + const row = screen.getByRole('treeitem') + expect(row.className).toMatch(/archived/) + expect(row.getAttribute('draggable')).toBe('false') + // No leading marker: the grayed row alone carries the archived look. + expect(screen.queryByRole('img', { name: '已归档' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '会话“Stored”的操作' })) + // Pin and archive are replaced by unarchive; rename and fork remain. + expect(screen.queryByRole('menuitem', { name: '置顶会话' })).toBeNull() + expect(screen.queryByRole('menuitem', { name: '归档会话' })).toBeNull() + expect(screen.getByRole('menuitem', { name: '重命名' })).toBeTruthy() + expect(screen.getByRole('menuitem', { name: '分叉会话' })).toBeTruthy() + fireEvent.click(screen.getByRole('menuitem', { name: '取消归档' })) + expect(onUnarchive).toHaveBeenCalledWith(node.id) + }) + + it('double-clicking the title opens rename with the current title, but not on a blank row', () => { + const onRename = vi.fn() + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Named', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + const view = render() + fireEvent.doubleClick(screen.getByText('Named')) + expect(onRename).toHaveBeenCalledWith(node.id, 'Named') + expect(onOpen).not.toHaveBeenCalled() + + // A blank placeholder has no content to rename: double-click is inert. + view.rerender() + fireEvent.doubleClick(screen.getByText('新会话')) + expect(onRename).toHaveBeenCalledOnce() + }) + + it('quick pin button toggles pin state without opening the session', () => { + const onPin = vi.fn() + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Quick', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: '置顶会话' })) + expect(onPin).toHaveBeenCalledWith(node.id, true) + + view.rerender() + fireEvent.click(screen.getByRole('button', { name: '取消置顶' })) + expect(onPin).toHaveBeenCalledWith(node.id, false) + expect(onOpen).not.toHaveBeenCalled() + }) + + it('quick archive button archives a live row and unarchives an archived one, hiding quick pin there', () => { + const onArchive = vi.fn() + const onUnarchive = vi.fn() + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'Quick', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: '归档会话' })) + expect(onArchive).toHaveBeenCalledWith(node.id) + + view.rerender() + // An archived row cannot be pinned: no quick pin button. + expect(screen.queryByRole('button', { name: '置顶会话' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '取消归档' })) + expect(onUnarchive).toHaveBeenCalledWith(node.id) + expect(onArchive).toHaveBeenCalledOnce() + expect(onOpen).not.toHaveBeenCalled() + }) + + it('flags an archived content-search result', () => { + const result: SearchResultNode = { + id: sid('result'), title: 'Old result', workspace: 'Workspace context', + running: false, runningSubagentCount: 0, completed: false, + hasActiveSchedule: false, archived: true, + } + render() + const row = screen.getByRole('treeitem') + expect(row.className).toMatch(/archived/) + // No leading marker: the grayed row alone carries the archived look. + expect(screen.queryByRole('img', { name: '已归档' })).toBeNull() + }) }) diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 4ce41c80ac..c67d1b1c85 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -7,6 +7,7 @@ import type { import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { + type ArchivedFilter, deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder, pinCurrentBlank, reconcileManualOrder, visibleSessionIds, workspaceLabel, UNGROUPED_KEY, } from '../src/client/tree.ts' @@ -45,6 +46,16 @@ const status = ( overrides: Partial = {}, ): SessionStatus => ({ running: undefined, pendingInteraction, completionUnread: false, ...overrides }) const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid) +const rowState = (options: { + pinned?: readonly string[] + archived?: readonly string[] + archivedFilter?: ArchivedFilter +} = {}) => ({ + pinnedSessions: (options.pinned ?? []).map(id => ({ sessionId: sid(id), pinnedAt: 1 })), + archivedSessionIds: (options.archived ?? []).map(sid), + archivedFilter: options.archivedFilter ?? 'default' as const, +}) +const noRows = rowState() const schedule = (id: string, scheduledAt: string): ScheduleRecord => ({ id: id as ScheduleId, kind: 'at', @@ -69,6 +80,16 @@ describe('Session ordering', () => { )).toEqual([sid('tie-a'), sid('tie-b'), sid('older')]) }) + it('ranks a pinned member by the later of its pin instant and update recency', () => { + const summaries = list(summary('stale-pin', 10), summary('fresh-pin', 30), summary('plain', 20)).byId + const pinnedAt = new Map([[sid('stale-pin'), 25], [sid('fresh-pin'), 5]]) + expect(orderByRecency( + [sid('stale-pin'), sid('fresh-pin'), sid('plain')], + summaries, + pinnedAt, + )).toEqual([sid('fresh-pin'), sid('stale-pin'), sid('plain')]) + }) + it('reconciles retained manual slots and appends newly known members by recency', () => { const summaries = list(summary('kept', 1), summary('newer', 30), summary('older', 20)).byId expect(reconcileManualOrder( @@ -92,7 +113,7 @@ describe('deriveGroups', () => { it('keeps caller-supplied Workspace and sessionIds order', () => { const sessions = list(summary('newer', 20), summary('older', 10)) const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])] - const groups = deriveGroups(sessions, workspaces, noArchive, noAttention, view(['first'])) + const groups = deriveGroups(sessions, workspaces, noRows, noAttention, view(['first'])) expect(groups.map(group => group.key)).toEqual(['first', 'empty']) expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) @@ -105,10 +126,10 @@ describe('deriveGroups', () => { status({ key: 'question:1', kind: 'plan-review', sessionId: awaiting.id } as SessionPendingInteraction, { running: true }), ]]) const grouped = deriveGroups( - sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']), + sessions, [workspace('project', ['awaiting'])], noRows, attention, view(['project']), ) expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true }) - expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), attention)[0]) + expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, attention)[0]) .toMatchObject({ pendingInteraction: 'plan-review', running: true }) }) @@ -121,14 +142,14 @@ describe('deriveGroups', () => { status({ key: `${kind}:1`, kind, sessionId: awaiting.id } as SessionPendingInteraction), ]]) - expect(deriveFlat(list(awaiting), [awaiting.id], attention)[0]?.pendingInteraction).toBe(kind) + expect(deriveFlat(list(awaiting), [awaiting.id], noRows, attention)[0]?.pendingInteraction).toBe(kind) }, ) it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) const groups = deriveGroups( - sessions, [workspace('first', ['owned'])], noArchive, noAttention, view([UNGROUPED_KEY]), + sessions, [workspace('first', ['owned'])], noRows, noAttention, view([UNGROUPED_KEY]), ) expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY]) expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) @@ -139,7 +160,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( sessions, [], - noArchive, + noRows, noAttention, view([UNGROUPED_KEY], ['two', 'stale', 'two']), ) @@ -155,7 +176,7 @@ describe('deriveGroups', () => { const sessions = list(real, currentBlank, staleBlank) const groups = deriveGroups( sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], - noArchive, noAttention, view(['first']), + noRows, noAttention, view(['first']), ) expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id]) const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)! @@ -168,7 +189,7 @@ describe('deriveGroups', () => { // A non-current blank stray never surfaces an Ungrouped bucket either. const strayGroups = deriveGroups( list({ ...summary('stray', 2), blank: true }), - [workspace('first', [])], noArchive, noAttention, view(), + [workspace('first', [])], noRows, noAttention, view(), ) expect(strayGroups.map(group => group.key)).toEqual(['first']) }) @@ -179,16 +200,16 @@ describe('deriveGroups', () => { const sessions = list(done, plain) const statuses: SessionStatusSnapshot = new Map([[done.id, status(undefined, { completionUnread: true })]]) const groups = deriveGroups( - sessions, [workspace('first', ['done', 'plain'])], noArchive, statuses, view(['first']), + sessions, [workspace('first', ['done', 'plain'])], noRows, statuses, view(['first']), ) const doneNode = groups[0]!.sessions.find(session => session.id === done.id)! const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)! expect(doneNode.completed).toBe(true) expect(plainNode.completed).toBe(false) - expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), statuses) + expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, statuses) .find(node => node.id === done.id)!.completed).toBe(true) const search = deriveSearchResults( - sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, + sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, 'default', statuses, { items: [], hasMore: false }, 10, ) expect(search.items[0]?.completed).toBe(true) @@ -215,12 +236,12 @@ describe('deriveGroups', () => { ] expect(deriveGroups( - sessions, workspaces, noArchive, noAttention, view(['project']), + sessions, workspaces, noRows, noAttention, view(['project']), )[0]!.sessions.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected) - expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention) + expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, noAttention) .map(node => [node.id, node.hasActiveSchedule])).toEqual(expected) expect(deriveSearchResults( - sessions, workspaces, 'project', noArchive, noAttention, { items: [], hasMore: false }, 10, + sessions, workspaces, 'project', noArchive, 'default', noAttention, { items: [], hasMore: false }, 10, ).items.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected) }) @@ -240,7 +261,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( sessions, [workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])], - noArchive, + noRows, noAttention, view(['first']), ) @@ -249,12 +270,12 @@ describe('deriveGroups', () => { expect(groups[0]!.sessionCount).toBe(2) expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 }) expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 }) - expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention) + expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, noAttention) .map(node => [node.id, node.runningSubagentCount])).toEqual([ [parent.id, 2], [fork.id, 1], ]) expect(deriveSearchResults( - sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive, + sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive, 'default', noAttention, { items: [], hasMore: false }, 10, ).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 }) }) @@ -272,7 +293,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), [], - noArchive, + noRows, noAttention, { expandedGroups: [UNGROUPED_KEY] }, ) @@ -285,7 +306,7 @@ describe('deriveGroups', () => { // Equal timestamps use ids as a deterministic tiebreak in either input order. expect(deriveGroups( - list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noAttention, view([UNGROUPED_KEY]), + list(summary('tie-a', 1), summary('tie-b', 1)), [], noRows, noAttention, view([UNGROUPED_KEY]), )[0]! .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')]) }) @@ -297,7 +318,7 @@ describe('deriveGroups', () => { byId: { [sid('present')]: summary('present', 1) }, } const groups = deriveGroups( - partial, [workspace('project', ['missing', 'present'])], noArchive, noAttention, view(['project']), + partial, [workspace('project', ['missing', 'present'])], noRows, noAttention, view(['project']), ) expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) }) @@ -308,7 +329,7 @@ describe('deriveGroups', () => { const looseGone = summary('loose-gone', 3, '/other') const sessions = list(kept, gone, looseGone) const groups = deriveGroups( - sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), + sessions, [workspace('first', ['kept', 'gone'])], rowState({ archived: ['gone', 'loose-gone'] }), noAttention, view(['first', UNGROUPED_KEY]), ) // The archived member drops from its group AND the archived stray never @@ -318,16 +339,47 @@ describe('deriveGroups', () => { expect(groups[0]!.sessionCount).toBe(1) }) + it('leads expanded groups with pinned rows in the supplied order', () => { + const sessions = list(summary('a', 4), summary('b', 3), summary('c', 2), summary('d', 1)) + const groups = deriveGroups( + sessions, [workspace('first', ['a', 'c', 'b', 'd'])], + rowState({ pinned: ['c', 'b'] }), + noAttention, view(['first']), + ) + // c precedes b per the supplied order even though b updated more recently: + // the pinned block never re-sorts, so manual pinned-to-pinned drags hold. + expect(groups[0]!.sessions.map(node => [node.id, node.pinned])).toEqual([ + [sid('c'), true], + [sid('b'), true], + [sid('a'), false], + [sid('d'), false], + ]) + }) + + it('keeps shown archived rows in place and never sections an archived pin', () => { + const sessions = list(summary('top', 3), summary('mid', 2), summary('low', 1)) + const groups = deriveGroups( + sessions, [workspace('first', ['top', 'mid', 'low'])], + rowState({ pinned: ['mid'], archived: ['mid'], archivedFilter: 'show' }), + noAttention, view(['first']), + ) + expect(groups[0]!.sessions.map(node => [node.id, node.pinned, node.archived])).toEqual([ + [sid('top'), false, false], + [sid('mid'), false, true], + [sid('low'), false, false], + ]) + }) + it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => { const owned = summary('owned', 1) const loose = summary('loose', 2) const ws = workspace('project', ['owned']) const ownedGroups = deriveGroups( - withMain(list(owned, loose), owned.id), [ws], noArchive, noAttention, view(), + withMain(list(owned, loose), owned.id), [ws], noRows, noAttention, view(), ) expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true) const looseGroups = deriveGroups( - withMain(list(owned, loose), loose.id), [ws], noArchive, noAttention, view(), + withMain(list(owned, loose), loose.id), [ws], noRows, noAttention, view(), ) expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true) }) @@ -339,7 +391,7 @@ describe('deriveFlat', () => { const child = { ...summary('child', 30), parentId: parent.id } const tieB = summary('tie-b', 20) const tieA = summary('tie-a', 20) - const rows = deriveFlat(list(parent, child, tieB, tieA), [parent.id, tieA.id, child.id, tieB.id], noAttention) + const rows = deriveFlat(list(parent, child, tieB, tieA), [parent.id, tieA.id, child.id, tieB.id], noRows, noAttention) expect(rows.map(row => row.id)).toEqual([parent.id, tieA.id, child.id, tieB.id]) }) @@ -347,20 +399,20 @@ describe('deriveFlat', () => { const parent = summary('parent', 1) const fork = { ...summary('fork', 2), parentId: parent.id } const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const } - const ids = visibleSessionIds(withMain(list(parent, fork, subagent), subagent.id), noArchive) + const ids = visibleSessionIds(withMain(list(parent, fork, subagent), subagent.id), noArchive, 'default') expect(ids).toEqual([parent.id, fork.id]) }) it('tolerates ids whose summary has not landed yet', () => { const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } - expect(visibleSessionIds(partial, noArchive)).toEqual([sid('present')]) + expect(visibleSessionIds(partial, noArchive, 'default')).toEqual([sid('present')]) }) it('shows only the current blank session and excludes blanks from search', () => { const currentBlank = { ...summary('current-blank', 9), blank: true, retainedBy: { mainView: 1 } } const staleBlank = { ...summary('stale-blank', 8), blank: true } const sessions = list(currentBlank, summary('real', 1), staleBlank) - const rows = deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention) + const rows = deriveFlat(sessions, visibleSessionIds(sessions, noArchive, 'default'), noRows, noAttention) expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')]) expect(rows.map(row => row.title)).toEqual(['', 'real']) expect(rows.map(row => row.blank)).toEqual([true, false]) @@ -369,7 +421,29 @@ describe('deriveFlat', () => { it('hides archived sessions in flat mode', () => { const kept = summary('kept', 1) const gone = summary('gone', 2) - expect(visibleSessionIds(list(kept, gone), archived('gone'))).toEqual([kept.id]) + expect(visibleSessionIds(list(kept, gone), archived('gone'), 'default')).toEqual([kept.id]) + }) + + it('keeps archived sessions visible while the view shows them', () => { + const kept = summary('kept', 1) + const gone = summary('gone', 2) + expect(visibleSessionIds(list(kept, gone), archived('gone'), 'show')).toEqual([kept.id, gone.id]) + }) + + it('restricts the view to archived sessions under the only filter', () => { + const kept = summary('kept', 1) + const gone = summary('gone', 2) + expect(visibleSessionIds(list(kept, gone), archived('gone'), 'only')).toEqual([gone.id]) + }) + + it('leads the flat list with pinned rows in the supplied order', () => { + const sessions = list(summary('a', 2), summary('b', 2), summary('c', 2), summary('d', 3)) + const rows = deriveFlat( + sessions, [sid('b'), sid('a'), sid('c'), sid('d')], rowState({ pinned: ['c', 'b', 'a'] }), noAttention, + ) + expect(rows.map(row => [row.id, row.pinned])).toEqual([ + [sid('b'), true], [sid('a'), true], [sid('c'), true], [sid('d'), false], + ]) }) }) @@ -384,12 +458,33 @@ describe('deriveSearchResults archive filtering', () => { [], 'needle', archived('gone'), + 'default', noAttention, { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false }, 10, ) expect(result.items.map(item => item.id)).toEqual([hit.id]) }) + + it('matches archived sessions and flags them while the view shows them', () => { + const gone = summary('gone', 1) + gone.displayTitle = 'Needle archived' + const result = deriveSearchResults( + list(gone), [], 'needle', archived('gone'), 'show', noAttention, { items: [], hasMore: false }, 10, + ) + expect(result.items.map(item => [item.id, item.archived])).toEqual([[gone.id, true]]) + }) + + it('matches only archived sessions under the only filter', () => { + const hit = summary('hit', 2) + hit.displayTitle = 'Needle row' + const gone = summary('gone', 1) + gone.displayTitle = 'Needle archived' + const result = deriveSearchResults( + list(hit, gone), [], 'needle', archived('gone'), 'only', noAttention, { items: [], hasMore: false }, 10, + ) + expect(result.items.map(item => [item.id, item.archived])).toEqual([[gone.id, true]]) + }) }) describe('deriveSearchResults', () => { @@ -409,6 +504,7 @@ describe('deriveSearchResults', () => { ], ' NEEDLE ', noArchive, + 'default', new Map([[titleHit.id, status({ key: 'question:1', kind: 'plan-review', sessionId: titleHit.id, } as SessionPendingInteraction)]]), @@ -435,6 +531,7 @@ describe('deriveSearchResults', () => { pendingInteraction: 'plan-review', completed: false, hasActiveSchedule: false, + archived: false, snippet: 'title session body excerpt', }, { @@ -445,6 +542,7 @@ describe('deriveSearchResults', () => { runningSubagentCount: 0, completed: false, hasActiveSchedule: false, + archived: false, }, { id: contentHit.id, @@ -454,6 +552,7 @@ describe('deriveSearchResults', () => { runningSubagentCount: 0, completed: false, hasActiveSchedule: false, + archived: false, snippet: 'body needle excerpt', }, ], @@ -472,6 +571,7 @@ describe('deriveSearchResults', () => { [workspace('first', ['opaque-current', 'new session stale'])], 'new session', noArchive, + 'default', noAttention, { items: [ @@ -496,6 +596,7 @@ describe('deriveSearchResults', () => { [], 'needle', noArchive, + 'default', noAttention, { items: [], hasMore: false }, 3, @@ -508,13 +609,14 @@ describe('deriveSearchResults', () => { [], 'needle', noArchive, + 'default', noAttention, { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true }, 3, ) expect(backendMore.items).toHaveLength(1) expect(backendMore.hasMore).toBe(true) - expect(deriveSearchResults(list(), [], ' ', noArchive, noAttention, { items: [], hasMore: true }, 3)) + expect(deriveSearchResults(list(), [], ' ', noArchive, 'default', noAttention, { items: [], hasMore: true }, 3)) .toEqual({ items: [], hasMore: false }) }) }) @@ -565,6 +667,13 @@ describe('createWorkspaceViewStore', () => { expect(store.getSnapshot().sessionOrderByAccount).toEqual({}) }) + it('records the one-time archive hint', () => { + const store = createWorkspaceViewStore().create() + expect(store.getSnapshot().archiveHintSeen).toBe(false) + store.actions.markArchiveHintSeen() + expect(store.getSnapshot().archiveHintSeen).toBe(true) + }) + it('removes view state outside the retained Workspace key set', () => { const store = createWorkspaceViewStore().create() store.actions.setGroupExpanded('', true) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index e372891f5c..b127ec558e 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -14,7 +14,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { DirectoryFlowOwnerProps, WorkspaceBrowserProps } from '../src/client/contract/slots.ts' import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' import { UNGROUPED_KEY } from '../src/client/tree.ts' -import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' +import { muteNextRowAnimations, rowTransition, WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. @@ -67,7 +67,15 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView const workspaceState = ( items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = [], -): WorkspaceSnapshot => ({ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null }) + pinnedSessionIds: readonly SessionId[] = [], +): WorkspaceSnapshot => ({ + items, + archivedSessionIds, + pinnedSessions: pinnedSessionIds.map(sessionId => ({ sessionId, pinnedAt: 1 })), + state: 'idle', + phase: 'ready', + error: null, +}) const noPendingInteraction: SessionStatusSnapshot = new Map() function hook(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } @@ -106,6 +114,9 @@ function mount(overrides: Partial = {}) { renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), archiveSession: vi.fn(async () => {}), + unarchiveSession: vi.fn(async () => {}), + pinSession: vi.fn(async () => {}), + unpinSession: vi.fn(async () => {}), insertWorkspaceBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), @@ -238,7 +249,7 @@ describe('WorkspaceBrowser', () => { const current = screen.getByText('current').closest('[role="treeitem"]') expect(current?.getAttribute('aria-selected')).toBe('false') fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') expect(document.activeElement).toBe(input) fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) const add = screen.getByRole('button', { name: '添加工作区' }) @@ -400,9 +411,9 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByRole('button', { name: '视图选项' })) expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label - expect(screen.getByRole('separator')).toBeTruthy() + expect(screen.getAllByRole('separator')).toHaveLength(2) expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ - '按工作区', '按工作区树', '单列表', '手动排序', '最近更新', + '按工作区', '按工作区树', '单列表', '手动排序', '最近更新', '显示已归档', '仅显示已归档', ]) expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy() expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy() @@ -428,6 +439,76 @@ describe('WorkspaceBrowser', () => { expect(b.store.getSnapshot().groupBy).toBe('workspace') }) + it('picking 显示已归档 keeps existing rows and reveals archived ones in place', () => { + mount({ + useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['kept', 'stored'])], [sid('stored')])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.queryByText('stored')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' })) + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.getByText('stored')).toBeTruthy() + }) + + it('the two archived filters are mutually exclusive and re-picking returns to default', () => { + const b = mount({ + useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['kept', 'stored'])], [sid('stored')])), + }) + fireEvent.click(screen.getByText('alpha')) + const pick = (name: string) => { + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name })) + } + + // 仅显示已归档 hides the live rows and shows the archived one. + pick('仅显示已归档') + expect(b.store.getSnapshot().archivedFilter).toBe('only') + expect(screen.queryByText('kept')).toBeNull() + expect(screen.getByText('stored')).toBeTruthy() + + // Picking the other filter replaces it: everything visible. + pick('显示已归档') + expect(b.store.getSnapshot().archivedFilter).toBe('show') + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.getByText('stored')).toBeTruthy() + + // Re-picking the selected filter returns to the default hidden view. + pick('显示已归档') + expect(b.store.getSnapshot().archivedFilter).toBe('default') + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.queryByText('stored')).toBeNull() + + // The same toggle-off applies to 仅显示已归档. + pick('仅显示已归档') + pick('仅显示已归档') + expect(b.store.getSnapshot().archivedFilter).toBe('default') + expect(screen.getByText('kept')).toBeTruthy() + expect(screen.queryByText('stored')).toBeNull() + }) + + it('keeps the picked archived filter across remounts through the view store', () => { + const seats = { + useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['kept', 'stored'])], [sid('stored')])), + } + const b = mount(seats) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' })) + expect(b.store.getSnapshot().archivedFilter).toBe('show') + + cleanup() + const remounted = mount(seats) + expect(remounted.store.getSnapshot().archivedFilter).toBe('show') + // The persisted group expansion also survives, so the rows are already out. + expect(screen.getByText('stored')).toBeTruthy() + }) + it('keeps Workspaces as siblings by default and restores the selected tree grouping', () => { const workspaces = hook(workspaceState([ { ...workspace('root', [], 'Projects'), path: '/projects' }, @@ -714,6 +795,199 @@ describe('WorkspaceBrowser', () => { } }) + it('fronts a freshly pinned row in the saved manual orders and seeds missing accounts', async () => { + const pinSession = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])), + pinSession, + }) + act(() => { b.store.actions.setSessionOrder('alpha', ['one', 'two', 'three'], {}) }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“three”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' })) + expect(pinSession).toHaveBeenCalledWith(sid('three')) + // The seeded group order fronts the pin; the flat account starts from the + // pinned row and the membership reconciler appends the remaining rows. + await waitFor(() => { + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'one', 'two']) + }) + expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['three', 'one', 'two']) + }) + + it('leaves saved orders alone for updated-mode pins and for unpins', async () => { + localStorage.clear() + const pinSession = vi.fn(async () => {}) + const unpinSession = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + pinSession, unpinSession, + }) + expect(b.store.getSnapshot().orderBy).toBe('updated') + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“two”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' })) + expect(pinSession).toHaveBeenCalledWith(sid('two')) + await act(async () => { await Promise.resolve() }) + expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({}) + + // Unpinning in manual mode does not rewrite any saved slot: the orders the + // membership reconciler seeded on entering manual mode stay as they are. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])], [], [sid('two')])) }) + act(() => { b.store.actions.setOrderBy('manual', {}) }) + const before = b.store.getSnapshot().sessionOrderByAccount + expect(before).toEqual({ + alpha: ['one', 'two'], + [UNGROUPED_KEY]: [], + [FLAT_SESSION_ORDER_KEY]: ['one', 'two'], + }) + fireEvent.click(screen.getByRole('button', { name: '会话“two”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '取消置顶' })) + expect(unpinSession).toHaveBeenCalledWith(sid('two')) + await act(async () => { await Promise.resolve() }) + expect(b.store.getSnapshot().sessionOrderByAccount).toEqual(before) + }) + + it('surfaces pin and unpin rejections as toasts', async () => { + const pinSession = vi.fn(async () => { throw new Error('pin wire down') }) + const unpinSession = vi.fn(async () => { throw new Error('unpin wire down') }) + const b = mount({ + useSessions: hook(sessionState([summary('one', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one'])])), + pinSession, unpinSession, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“one”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '置顶会话' })) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toContain('置顶失败,请稍后重试') + }) + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['one'])], [], [sid('one')])) }) + fireEvent.click(screen.getByRole('button', { name: '会话“one”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '取消置顶' })) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toContain('取消置顶失败,请稍后重试') + }) + }) + + it('teaches the filter once, then confirms later archives with an undo/filter toast', async () => { + const archiveSession = vi.fn(async () => {}) + const unarchiveSession = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])), + archiveSession, unarchiveSession, + }) + fireEvent.click(screen.getByText('alpha')) + const archive = (name: string) => { + fireEvent.click(screen.getByRole('button', { name: `会话“${name}”的操作` })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + } + const flush = () => act(async () => { await Promise.resolve() }) + + // First archive: the anchored callout teaches the filter, no toast. + archive('one') + await flush() + expect(screen.getByText('已归档的会话可以通过筛选展示')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(b.store.getSnapshot().archiveHintSeen).toBe(true) + + // Later archives confirm with the actionable toast; a back-to-back + // archive replaces the still-showing toast. + archive('two') + await flush() + expect(screen.getByRole('alert').textContent).toContain('会话已归档') + archive('three') + await flush() + expect(screen.getAllByRole('alert')).toHaveLength(1) + + // 撤销 unarchives the toast's session and dismisses the banner. + fireEvent.click(screen.getByRole('button', { name: '撤销' })) + expect(unarchiveSession).toHaveBeenCalledWith(sid('three')) + expect(screen.queryByRole('alert')).toBeNull() + + // 筛选查看 dismisses the banner and opens the view-options menu. + archive('two') + await flush() + fireEvent.click(screen.getByRole('button', { name: '筛选已归档会话' })) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('menuitem', { name: '显示已归档' })).toBeTruthy() + + // A chrome remount (window resized through the rail breakpoint) must not + // replay the toast-action bump and reopen the closed menu. + fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' })) + expect(screen.queryByRole('menuitem', { name: '显示已归档' })).toBeNull() + rerender(b, { wide: false }) + rerender(b, { wide: true }) + expect(screen.queryByRole('menuitem', { name: '显示已归档' })).toBeNull() + }) + + it('offers unarchive on archived search results only', async () => { + const unarchiveSession = vi.fn(async () => {}) + mount({ + useSessions: hook(sessionState([summary('alive', 2), summary('gone', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alive', 'gone'])], [sid('gone')])), + unarchiveSession, + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' })) + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'e' } }) + await act(async () => { await Promise.resolve() }) + const tree = screen.getByRole('tree', { name: '搜索结果' }) + expect(within(tree).getByText('alive')).toBeTruthy() + expect(within(tree).getByText('gone')).toBeTruthy() + const unarchive = within(tree).getAllByRole('button', { name: '取消归档' }) + expect(unarchive).toHaveLength(1) + fireEvent.click(unarchive[0]!) + expect(unarchiveSession).toHaveBeenCalledWith(sid('gone')) + }) + + it('fades appearing and disappearing rows quicker than it glides moved ones, and mutes gesture passes', async () => { + class FakeKeyframeEffect { + constructor( + readonly target: Element | null, + readonly keyframes: readonly Keyframe[], + readonly options: KeyframeEffectOptions, + ) {} + } + vi.stubGlobal('KeyframeEffect', FakeKeyframeEffect) + try { + const el = document.createElement('div') + const effect = (value: unknown): FakeKeyframeEffect => value as FakeKeyframeEffect + const added = effect(rowTransition(el, 'add')) + expect(added.options).toEqual({ duration: 100, easing: 'ease-out' }) + expect(added.keyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]) + const removed = effect(rowTransition(el, 'remove')) + expect(removed.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }]) + const moved = effect(rowTransition( + el, 'remain', + { top: 40, left: 10, width: 200, height: 30 }, + { top: 10, left: 10, width: 200, height: 30 }, + )) + expect(moved.options).toEqual({ duration: 200, easing: 'ease-out' }) + expect(moved.keyframes).toEqual([ + { transform: 'translate(0px, 30px)' }, + { transform: 'translate(0, 0)' }, + ]) + const settled = effect(rowTransition(el, 'remain')) + expect(settled.keyframes).toEqual([ + { transform: 'translate(0px, 0px)' }, + { transform: 'translate(0, 0)' }, + ]) + // A muted pass (expand/collapse or drag commit) settles instantly, and + // the mute lifts by the next frame. + muteNextRowAnimations() + expect(effect(rowTransition(el, 'add')).options).toEqual({ duration: 0 }) + expect(effect(rowTransition(el, 'remain')).options).toEqual({ duration: 0 }) + await new Promise(resolve => requestAnimationFrame(resolve)) + expect(effect(rowTransition(el, 'add')).options).toEqual({ duration: 100, easing: 'ease-out' }) + } finally { + vi.unstubAllGlobals() + } + }) + it('renders a fork child as a top-level row without a session twist', () => { const parent = summary('parent-s', 2) const child = { ...summary('child-s', 1), parentId: parent.id } @@ -797,9 +1071,9 @@ describe('WorkspaceBrowser', () => { expect(screen.getAllByText('新会话')).toHaveLength(1) // Search excludes blank rows entirely — neither the canonical stored // title nor the localized display label participates in matching. - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'new session' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'new session' } }) expect(screen.queryByText('新会话')).toBeNull() - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: '新会话' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: '新会话' } }) expect(screen.queryByText('新会话')).toBeNull() }) @@ -881,13 +1155,15 @@ describe('WorkspaceBrowser', () => { useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), }) fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) const resultTree = screen.getByRole('tree', { name: '搜索结果' }) expect(screen.getByText('Needle row')).toBeTruthy() expect(screen.queryByText('Other row')).toBeNull() const status = screen.getByRole('status') - expect(status.textContent).toBe('正在搜索会话历史…') + expect(status.getAttribute('aria-label')).toBe('正在搜索会话历史…') + // Local matches already show, so only one skeleton row trails them. + expect(status.childElementCount).toBe(1) expect(resultTree.contains(status)).toBe(false) fireEvent.change(input, { target: { value: 'zzz' } }) @@ -913,7 +1189,7 @@ describe('WorkspaceBrowser', () => { expect(search.getAttribute('aria-expanded')).toBe('false') fireEvent.click(search) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: ' ' } }) fireEvent.click(document.body) expect(search.getAttribute('aria-expanded')).toBe('false') @@ -953,9 +1229,9 @@ describe('WorkspaceBrowser', () => { open, searchSessions, }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'waterfall token' } }) - expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() + expect(screen.getByRole('status', { name: '正在搜索会话历史…' })).toBeTruthy() expect(screen.queryByText('Research notes')).toBeNull() await act(async () => { await vi.advanceTimersByTimeAsync(250) }) @@ -992,7 +1268,7 @@ describe('WorkspaceBrowser', () => { ]) const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const } const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1027,7 +1303,7 @@ describe('WorkspaceBrowser', () => { state: 'loading' as const, } const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(reconnecting) }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1059,7 +1335,7 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('research', [...sessions.ids])])), }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1073,7 +1349,7 @@ describe('WorkspaceBrowser', () => { const sessions = sessionState([summary('target', 1, { displayTitle: 'Needle session' })]) const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const } const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1097,7 +1373,7 @@ describe('WorkspaceBrowser', () => { }) fireEvent.click(screen.getByRole('button', { name: '视图选项' })) fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'needle' } }) fireEvent.click(screen.getByRole('treeitem')) @@ -1116,7 +1392,7 @@ describe('WorkspaceBrowser', () => { try { const searchSessions = vi.fn(async () => ({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') expect(input.maxLength).toBe(500) fireEvent.change(input, { target: { value: 'y'.repeat(501) } }) expect(input.value).toBe('y'.repeat(500)) @@ -1136,7 +1412,7 @@ describe('WorkspaceBrowser', () => { } }) - it('keeps local matches and shows a lightweight warning when Host search fails', async () => { + it('keeps local matches without a warning when Host search fails', async () => { vi.useFakeTimers() try { const searchSessions = vi.fn(async () => { throw new Error('index unavailable') }) @@ -1147,13 +1423,13 @@ describe('WorkspaceBrowser', () => { useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])), searchSessions, }) - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'needle' }, }) expect(screen.getByText('Needle title')).toBeTruthy() await act(async () => { await vi.advanceTimersByTimeAsync(250) }) expect(screen.getByText('Needle title')).toBeTruthy() - expect(screen.getByText('内容搜索暂不可用,仅显示名称匹配。')).toBeTruthy() + expect(screen.queryByText('内容搜索暂不可用,仅显示名称匹配。')).toBeNull() expect(screen.queryByText('无匹配会话')).toBeNull() } finally { vi.useRealTimers() @@ -1184,7 +1460,7 @@ describe('WorkspaceBrowser', () => { ])), searchSessions, }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal @@ -1218,7 +1494,7 @@ describe('WorkspaceBrowser', () => { ? first : Promise.resolve({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) @@ -1241,8 +1517,9 @@ describe('WorkspaceBrowser', () => { b.store.actions.setGroupBy('flat') rerender(b, {}) expect(screen.getByText('暂无会话')).toBeTruthy() - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'x' } }) - expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'x' } }) + // No matches yet, so the empty list shows two skeleton rows. + expect(screen.getByRole('status', { name: '正在搜索会话历史…' }).childElementCount).toBe(2) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) expect(screen.getByText('无匹配会话')).toBeTruthy() } finally { @@ -1257,12 +1534,12 @@ describe('WorkspaceBrowser', () => { const b = mount({ wide: false, expandSidebar }) // No wide chrome in rail state. expect(screen.queryByText('工作区')).toBeNull() - expect(screen.queryByPlaceholderText('搜索会话…')).toBeNull() + expect(screen.queryByPlaceholderText('搜索会话名称')).toBeNull() fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) expect(expandSidebar).toHaveBeenCalledTimes(1) // The wide flip mounts the input and focuses it after the slide. rerender(b, { wide: true }) - const input = screen.getByPlaceholderText('搜索会话…') + const input = screen.getByPlaceholderText('搜索会话名称') act(() => { vi.advanceTimersByTime(300) }) expect(document.activeElement).toBe(input) // Wide search button is decorative (tabIndex -1, no expand call). @@ -1286,7 +1563,7 @@ describe('WorkspaceBrowser', () => { fireEvent.click(document.body) expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('true') act(() => { vi.advanceTimersByTime(300) }) - expect(document.activeElement).toBe(screen.getByPlaceholderText('搜索会话…')) + expect(document.activeElement).toBe(screen.getByPlaceholderText('搜索会话名称')) // The gesture has settled: outside clicks dismiss the search again. fireEvent.click(document.body) expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('false') @@ -1428,6 +1705,160 @@ describe('WorkspaceBrowser', () => { expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one', 'three']) }) + it('drags a pinned row within the pinned block, rewriting only pinned slots', () => { + const sessions = sessionState([ + summary('one', 4), summary('two', 3), summary('three', 2), summary('four', 1), + ]) + // Interleaved manual slots: pinned rows front in render while unpinned + // members keep their account positions when the pinned block reorders. + createWorkspaceViewStore().create().actions.setSessionOrder('alpha', ['three', 'one', 'four', 'two'], {}) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState( + [workspace('alpha', ['one', 'two', 'three', 'four'])], [], [sid('one'), sid('two')], + )), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([ + expect.stringContaining('one'), expect.stringContaining('two'), + expect.stringContaining('three'), expect.stringContaining('four'), + ]) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + one.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(two, { dataTransfer: dragData() }) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'two', 'four', 'one']) + expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([ + expect.stringContaining('two'), expect.stringContaining('one'), + expect.stringContaining('three'), expect.stringContaining('four'), + ]) + + // Dropping the trailing pinned row after the leading one restates the + // current block: a no-op that writes nothing. + const oneAgain = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const twoAgain = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + twoAgain.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(oneAgain, { dataTransfer: dragData() }) + fireDrag(twoAgain, 'drop', 130) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['three', 'two', 'four', 'one']) + }) + + it('keeps pinned and unpinned rows in separate drag domains', () => { + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])], [], [sid('one')])), + }) + fireEvent.click(screen.getByText('alpha')) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + for (const row of [one, two]) { + row.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + } + const before = b.store.getSnapshot().sessionOrderByAccount.alpha + + // A pinned source ignores unpinned targets… + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'dragOver', 105) + fireDrag(two, 'drop', 105) + fireEvent.dragEnd(one) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(before) + + // …and an unpinned source ignores pinned targets. + fireEvent.dragStart(two, { dataTransfer: dragData() }) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + fireEvent.dragEnd(two) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(before) + }) + + it('ignores a pinned drop whose pin state moved in flight', () => { + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState( + [workspace('alpha', ['one', 'two', 'three'])], [], [sid('one'), sid('two')], + )), + }) + fireEvent.click(screen.getByText('alpha')) + const before = b.store.getSnapshot().sessionOrderByAccount.alpha + const markTarget = (): HTMLElement => { + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + two.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + return two + } + + // The target unpins in flight: its id left the pinned block. + let one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(markTarget(), 'dragOver', 105) + rerender(b, { + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])], [], [sid('one')])), + }) + fireEvent.dragEnd(screen.getByText('one').closest('[role="treeitem"]') as HTMLElement) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(before) + + // The source unpins in flight. + rerender(b, { + useWorkspaces: hook(workspaceState( + [workspace('alpha', ['one', 'two', 'three'])], [], [sid('one'), sid('two')], + )), + }) + one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(markTarget(), 'dragOver', 105) + rerender(b, { + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])], [], [sid('two')])), + }) + fireEvent.dragEnd(screen.getByText('one').closest('[role="treeitem"]') as HTMLElement) + expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(before) + }) + + it('drags pinned rows within the flat pinned block and keeps cross-block targets inert', async () => { + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([], [], [sid('one'), sid('two')])), + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + await waitFor(() => { + expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]) + .toEqual(['one', 'two', 'three']) + }) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + for (const row of [one, two]) { + row.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + } + fireEvent.dragStart(two, { dataTransfer: dragData() }) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]) + .toEqual(['two', 'one', 'three']) + + // An unpinned source ignores pinned targets. + const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(three, { dataTransfer: dragData() }) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + fireEvent.dragEnd(three) + expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]) + .toEqual(['two', 'one', 'three']) + }) + it('persists Ungrouped drag order in both modes', async () => { const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) const b = mount({ @@ -1686,7 +2117,7 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), }) - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'needle' } }) const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement expect(row.hasAttribute('draggable')).toBe(false) }) @@ -1817,7 +2248,7 @@ describe('Workspace tree grouping', () => { useWorkspaces: hook(workspaceState([root, team, child])), useSessions: hook(sessionState([summary('child-session', 1)])), }) - fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'child-session' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'child-session' } }) fireEvent.click(screen.getByRole('treeitem')) expect(b.store.getSnapshot().groupExpansion).toEqual({ child: true }) expect(screen.getByText('child-session')).toBeTruthy() diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index e3c3437d58..28f8aaacae 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -39,7 +39,7 @@ const sessions: SessionListState = { } const noPendingInteraction: SessionStatusSnapshot = new Map() const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({ - items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: [], pinnedSessions: [], state: 'idle', phase: 'ready', error: null, }) function anchor(): { current: HTMLElement } { const element = document.createElement('button') diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 79423d9c03..92f51f3328 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -88,6 +88,7 @@ function workspaceState( return { items, archivedSessionIds, + pinnedSessions: [], phase, state: phase === 'ready' ? 'idle' : 'loading', error: null, @@ -189,6 +190,8 @@ class FakeWorkspaces implements IWorkspaces { declare readonly delete: IWorkspaces['delete'] declare readonly insertBefore: IWorkspaces['insertBefore'] declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore'] + declare readonly pinSession: IWorkspaces['pinSession'] + declare readonly unpinSession: IWorkspaces['unpinSession'] constructor(initial: WorkspaceSnapshot) { this.list = new MutableSource(initial) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 84a4e1a640..35a5af86e3 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -59,6 +59,9 @@ { "path": "../ui-session" }, + { + "path": "../../util/values" + }, { "path": "../../util/workspace-path" } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index d2c50baf26..487dea1160 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2269,7 +2269,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-settings-general GeneralSection id \'general\'', 'client-ui-settings-models ModelsSection id \'models\'', 'client-ui-settings-plugins PluginsSettingsSection id \'plugins\'', - 'client-ui-settings-unarchive-sessions ArchivedSessionsSection id \'archived-sessions\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.section\', () => ctx.slots.register(\n { name: \'settings.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts index 707028af66..fe31f35c53 100644 --- a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -42,6 +42,6 @@ export const remoteDefaultResponses: RemoteTable = { // api-session-controller client `apply`: the control stream's opening baseline, then open. 'session/control': openStream([{ type: 'baseline', value: { jobs: {}, projections: {} } }]), // api-workspace-controller client `apply`: the follow stream's opening baseline, then open. - 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [] } }]), + 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessions: [] } }]), }, } diff --git a/packages/test-support/client-runtime/src/fixtures.ts b/packages/test-support/client-runtime/src/fixtures.ts index 8e39f3c112..5e621ee6fc 100644 --- a/packages/test-support/client-runtime/src/fixtures.ts +++ b/packages/test-support/client-runtime/src/fixtures.ts @@ -111,6 +111,7 @@ export function workspaceSnapshot(): WorkspaceSnapshot { return { items: [], archivedSessionIds: [], + pinnedSessions: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/test-support/client-runtime/src/workspaces.ts b/packages/test-support/client-runtime/src/workspaces.ts index 15ecc71d58..8cba3707b5 100644 --- a/packages/test-support/client-runtime/src/workspaces.ts +++ b/packages/test-support/client-runtime/src/workspaces.ts @@ -159,4 +159,41 @@ export class TestWorkspaces implements IWorkspaces { draft.archivedSessionIds = draft.archivedSessionIds.filter(id => id !== sessionId) }) } + + /** + * Pin a session (recorded). The default mirrors the production face's + * observable effect: the id leads the list state's pin set. + * @param sessionId - session to pin. + */ + async pinSession(sessionId: SessionId): Promise { + this.calls.push({ method: 'pinSession', args: [sessionId] }) + const stub = this.stubs.get('pinSession') + if (stub !== undefined) { + await (stub(sessionId) as Promise) + return + } + await this.update((draft) => { + draft.pinnedSessions = [ + { sessionId, pinnedAt: Date.now() }, + ...draft.pinnedSessions.filter(entry => entry.sessionId !== sessionId), + ] + }) + } + + /** + * Unpin a session (recorded). The default mirrors the production face's + * observable effect: the id leaves the list state's pin set. + * @param sessionId - session to unpin. + */ + async unpinSession(sessionId: SessionId): Promise { + this.calls.push({ method: 'unpinSession', args: [sessionId] }) + const stub = this.stubs.get('unpinSession') + if (stub !== undefined) { + await (stub(sessionId) as Promise) + return + } + await this.update((draft) => { + draft.pinnedSessions = draft.pinnedSessions.filter(entry => entry.sessionId !== sessionId) + }) + } } diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 2d69aafe6c..1c510685c7 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -17,12 +17,12 @@ import type { WorkspaceEntityHost } from './entity.ts' export { WorkspaceMoveInvalidError } from './entity.ts' import { defaultWorkspaceTitle, realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' -import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' +import type { PinnedSession, WorkspaceDomainState, WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts' export type { Workspace } from './types.ts' export { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from './spec.ts' -export type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' +export type { PinnedSession, WorkspaceDomainState, WorkspaceRecord } from './spec.ts' export { realpathNormalize } from './paths.ts' /** Identifies one workspace record (see `src/types.ts` for the brand rationale). */ @@ -38,16 +38,29 @@ export function WorkspaceId(id: string): WorkspaceId { } /** - * An archiveSession request named a session neither live nor in session - * persistence — a definite miss only; storage faults propagate as themselves. + * An archiveSession or pinSession request named a session neither live nor in + * session persistence — a definite miss only; storage faults propagate as + * themselves. */ export class WorkspaceUnknownSessionError extends Error { /** * @param sessionId - The unknown session id. + * @param verb - The registry operation that named the session. + */ + constructor(readonly sessionId: SessionId, verb: 'archive' | 'pin') { + super(`cannot ${verb} session '${sessionId}': live sessions and session persistence hold no such session`) + this.name = 'WorkspaceUnknownSessionError' + } +} + +/** A pinSession request named a session currently in the archive set; pinning and archival are mutually exclusive. */ +export class WorkspaceArchivedSessionPinError extends Error { + /** + * @param sessionId - The archived session id. */ constructor(readonly sessionId: SessionId) { - super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`) - this.name = 'WorkspaceUnknownSessionError' + super(`cannot pin session '${sessionId}': the session is archived`) + this.name = 'WorkspaceArchivedSessionPinError' } } @@ -236,7 +249,9 @@ export class WorkspaceRegistry extends Service { /** * Archive one session durably. The session must exist (live or in session * persistence); its workspace accounting — or lack of one — is irrelevant. - * An already archived id resolves without writing. + * Archiving drops the session's pin in the same durable write (pinning and + * archival are mutually exclusive). An already archived id resolves without + * writing. * @param sessionId - The session to archive. * @returns resolution after durability. */ @@ -246,10 +261,14 @@ export class WorkspaceRegistry extends Service { // check-then-write pair cannot interleave with another archive. if (this.requireState().archivedSessionIds.includes(sessionId)) return if (!(await this.sessionKnown(sessionId))) { - throw new WorkspaceUnknownSessionError(sessionId) + throw new WorkspaceUnknownSessionError(sessionId, 'archive') } const state = this.requireState() - await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] }) + await this.setState({ + ...state, + archivedSessionIds: [...state.archivedSessionIds, sessionId], + pinnedSessions: state.pinnedSessions.filter(entry => entry.id !== sessionId), + }) }) } @@ -276,6 +295,65 @@ export class WorkspaceRegistry extends Service { }) } + /** + * The registry-global pin set: sessions surfaced ahead of every unpinned + * session on grouping surfaces. Pinning never touches workspace accounting. + * @returns the pin entries in pin order (most recently pinned first), each + * carrying the epoch-millisecond `pinnedAt` it was pinned at. + */ + get pinnedSessions(): readonly PinnedSession[] { + return this.requireState().pinnedSessions + } + + /** + * Pin one session durably, prepending it to the registry-global pin set + * with the current wall-clock time as its `pinnedAt`. The session must + * exist (live or in session persistence) and must not be archived (pinning + * and archival are mutually exclusive). An already pinned id resolves + * without writing and keeps its original `pinnedAt`. + * @param sessionId - The session to pin. + * @returns resolution after durability. + */ + pinSession(sessionId: SessionId): Promise { + return this.enqueueOperation(async () => { + // The chain slot serializes against every other registry write, so this + // check-then-write pair cannot interleave with another pin or archive. + if (this.requireState().pinnedSessions.some(entry => entry.id === sessionId)) return + if (this.requireState().archivedSessionIds.includes(sessionId)) { + throw new WorkspaceArchivedSessionPinError(sessionId) + } + if (!(await this.sessionKnown(sessionId))) { + throw new WorkspaceUnknownSessionError(sessionId, 'pin') + } + const state = this.requireState() + await this.setState({ + ...state, + pinnedSessions: [{ id: sessionId, pinnedAt: Date.now() }, ...state.pinnedSessions], + }) + }) + } + + /** + * 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 { + return this.enqueueOperation(async () => { + // The chain slot serializes against every other registry write, so this + // check-then-write pair cannot interleave with a concurrent pin. + const state = this.requireState() + if (!state.pinnedSessions.some(entry => entry.id === sessionId)) return + await this.setState({ + ...state, + pinnedSessions: state.pinnedSessions.filter(entry => entry.id !== sessionId), + }) + }) + } + /** * Whether a session is live, header-indexed, or present in a fresh * persistence listing. Only a definite miss returns false — a failing @@ -353,6 +431,7 @@ export class WorkspaceRegistry extends Service { initialized: true, workspaceIds: [id, ...state.workspaceIds], archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, }) } catch (error) { this.entities.delete(id) @@ -385,6 +464,7 @@ export class WorkspaceRegistry extends Service { initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, } await this.setState({ ...nextState, @@ -442,6 +522,7 @@ export class WorkspaceRegistry extends Service { initialized: state.initialized, workspaceIds: state.workspaceIds, archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, }) } @@ -524,9 +605,19 @@ export class WorkspaceRegistry extends Service { .map(([id]) => id) if (!sameIds(state.workspaceIds, workspaceIds)) { - await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds }) + await this.setState({ + initialized: false, + workspaceIds, + archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, + }) } - await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds }) + await this.setState({ + initialized: true, + workspaceIds, + archivedSessionIds: state.archivedSessionIds, + pinnedSessions: state.pinnedSessions, + }) } private validateStoredState(state: WorkspaceDomainState): void { diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts index 089e039f27..ac1d737716 100644 --- a/packages/workspace/workspace/src/spec.ts +++ b/packages/workspace/workspace/src/spec.ts @@ -40,6 +40,19 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [ z.object({ operation: z.literal('delete'), workspaceId }), ]) +/** + * One registry-global pin entry: the session and the wall-clock epoch + * milliseconds it was pinned at. Grouping surfaces rank a pinned row by the + * later of `pinnedAt` and the session's own update recency. + */ +const pinnedSessionEntry = z.object({ + id: z.string().transform(value => brandString(value)), + pinnedAt: z.number(), +}) + +/** One stored pin entry inferred from {@link pinnedSessionEntry}. */ +export type PinnedSession = z.infer + /** * Durable registry state. `initialized` distinguishes a valid empty registry * from one that still needs the header-only history bootstrap; @@ -47,12 +60,16 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [ * the registry-global archive set layered over workspace accounting: an * archived session keeps its `sessionIds` slot (unarchiving must restore the * position), so the set never participates in the one-owner accounting - * invariant. Defaulted so records written before the field parse unchanged. + * invariant. `pinnedSessions` is the registry-global pin set in pin order + * (most recently pinned first); pinning and archival are mutually + * exclusive, so archiving drops the session's pin. Both session sets are + * defaulted so records written before the fields parse unchanged. */ export const workspaceDomainState = z.object({ initialized: z.boolean(), workspaceIds: z.array(workspaceId), archivedSessionIds: z.array(z.string().transform(value => brandString(value))).default([]), + pinnedSessions: z.array(pinnedSessionEntry).default([]), pendingMutation: workspacePendingMutation.optional(), }) @@ -70,7 +87,7 @@ export const workspaceDomainSpec = defineDomain({ version: 2, global: { schema: workspaceDomainState, - initial: { initialized: false, workspaceIds: [], archivedSessionIds: [] }, + initial: { initialized: false, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }, }, tables: { workspaces: domainTable(workspaceRecord) }, }) diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index c6e283868e..48a4d98753 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -17,7 +17,7 @@ import WorkspaceRegistry, { WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, } from '../src/index.ts' -import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' +import type { PinnedSession, WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' import { defaultWorkspaceTitle, fullyQualifiedWorkspacePath } from '../src/paths.ts' const DOMAIN_VERSION = 2 @@ -145,11 +145,12 @@ function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:0 } /** - * Media written before archivedSessionIds existed omit the field; keeping the - * fixtures in that shape continuously proves the schema default upgrades them. + * Media written before archivedSessionIds and pinnedSessions existed omit + * the fields; keeping the fixtures in that shape continuously proves the + * schema defaults upgrade them. */ -type StoredDomainState = Omit - & Partial> +type StoredDomainState = Omit + & Partial> function storedPool( entries: Array<[string, WorkspaceRecord]>, @@ -172,6 +173,8 @@ function storedState(pool: MemoryMediaPool): WorkspaceDomainState { return pool.media.get('workspace')!.global as WorkspaceDomainState } +const pinnedIds = (entries: readonly PinnedSession[]): string[] => entries.map(entry => String(entry.id)) + let base: string const tempDirs: string[] = [] @@ -201,7 +204,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { await fiber.await() expect(ctx.workspaceRegistry.list()).toEqual([]) expect(list).toHaveBeenCalledTimes(1) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) }) it('bootstraps once from list headers only, in workspace/session createdAt order', async () => { @@ -235,6 +238,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { initialized: true, workspaceIds: result.registry.list().map(workspace => workspace.id), archivedSessionIds: [], + pinnedSessions: [], }) }) @@ -263,7 +267,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { const second = await harness({ pool, sessions: [header('late', late, 100)] }) expect(second.list).not.toHaveBeenCalled() expect(second.registry.list()).toEqual([]) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) }) it('reuses partial records after a bootstrap record write fails', async () => { @@ -517,7 +521,7 @@ describe('WorkspaceRegistry create and lookup', () => { await expect(result.registry.delete(workspace.id)).resolves.toBe(false) expect(result.registry.get(workspace.id)).toBeUndefined() expect(result.registry.list()).toEqual([]) - expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false) await expect(realpath(dir)).resolves.toBe(dir) expect(result.list).toHaveBeenCalledTimes(1) @@ -561,6 +565,7 @@ describe('WorkspaceRegistry create and lookup', () => { initialized: true, workspaceIds: [], archivedSessionIds: [], + pinnedSessions: [], pendingMutation: { operation: 'delete', workspaceId: workspace.id }, }) const reregistered = await first.registry.create(dir) @@ -569,6 +574,7 @@ describe('WorkspaceRegistry create and lookup', () => { initialized: true, workspaceIds: [reregistered.id], archivedSessionIds: [], + pinnedSessions: [], }) await first.fiber.dispose() @@ -848,7 +854,7 @@ describe('header-validated membership projection', () => { const createRecovery = await harness({ pool: interruptedCreate }) expect(createRecovery.registry.list()).toEqual([]) expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false) - expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) const interruptedDelete = storedPool( [[deleteId, record(deleteDir, [])]], @@ -861,7 +867,7 @@ describe('header-validated membership projection', () => { const deleteRecovery = await harness({ pool: interruptedDelete }) expect(deleteRecovery.registry.list()).toEqual([]) expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false) - expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) + expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessions: [] }) const corruptPending = storedPool( [[deleteId, record(deleteDir, [])]], @@ -1033,3 +1039,123 @@ describe('registry-global session unarchive', () => { expect(second.registry.archivedSessionIds).toEqual(['kept']) }) }) + +describe('registry-global session pin', () => { + it('pins durably prepending with a pinnedAt stamp, idempotently skips repeats, and leaves accounting untouched', async () => { + const dir = await makeDir('pin-home') + const result = await harness({ sessions: [header('first', dir, 100), header('second', dir, 200)] }) + const workspace = result.registry.list()[0]! + expect(result.registry.pinnedSessions).toEqual([]) + + const before = Date.now() + await result.registry.pinSession(SessionId('first')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['first']) + const firstEntry = result.registry.pinnedSessions[0]! + expect(firstEntry.pinnedAt).toBeGreaterThanOrEqual(before) + expect(firstEntry.pinnedAt).toBeLessThanOrEqual(Date.now()) + // Pinning is a display-set write: the workspace account keeps the id. + expect(workspace.sessionIds).toContain('first') + expect(storedState(result.pool).pinnedSessions).toEqual([firstEntry]) + const changesAfterFirst = result.changes.filter(change => change.table === '').length + + await result.registry.pinSession(SessionId('first')) + // The idempotent repeat keeps the original pinnedAt, never rewrites the + // medium, and emits no change. + expect(result.registry.pinnedSessions).toEqual([firstEntry]) + expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst) + + // The most recent pin lands first (display order). + await result.registry.pinSession(SessionId('second')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['second', 'first']) + expect(result.registry.pinnedSessions[0]!.pinnedAt).toBeGreaterThanOrEqual(firstEntry.pinnedAt) + }) + + it('rejects pinning archived and unknown sessions without writing', async () => { + const dir = await makeDir('pin-rejects') + const result = await harness({ sessions: [header('stored', dir, 100)] }) + await result.registry.archiveSession(SessionId('stored')) + + await expect(result.registry.pinSession(SessionId('stored'))) + .rejects.toThrow(/cannot pin session 'stored': the session is archived/) + await expect(result.registry.pinSession(SessionId('ghost'))) + .rejects.toThrow(/cannot pin session 'ghost'/) + expect(storedState(result.pool).pinnedSessions).toEqual([]) + }) + + it('propagates a persistence-listing failure instead of reporting an unknown session', async () => { + const result = await harness({ sessions: [] }) + result.list.mockRejectedValueOnce(new Error('persistence backend down')) + // The storage fault is the error — never WorkspaceUnknownSessionError, + // which the API layer would misreport as session-not-found. + await expect(result.registry.pinSession(SessionId('unlisted'))) + .rejects.toThrow(/persistence backend down/) + expect(storedState(result.pool).pinnedSessions).toEqual([]) + }) + + it('archiving drops the pin in the same durable write', async () => { + const dir = await makeDir('pin-then-archive') + const result = await harness({ sessions: [header('s1', dir, 100), header('s2', dir, 200)] }) + await result.registry.pinSession(SessionId('s1')) + await result.registry.pinSession(SessionId('s2')) + + await result.registry.archiveSession(SessionId('s1')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['s2']) + expect(result.registry.archivedSessionIds).toEqual(['s1']) + const stored = storedState(result.pool) + expect(pinnedIds(stored.pinnedSessions)).toEqual(['s2']) + expect(stored.archivedSessionIds).toEqual(['s1']) + }) + + it('restores the pin set across restarts', async () => { + const dir = await makeDir('pin-restart') + const pool = new MemoryMediaPool() + const sessions = [header('kept', dir, 100)] + const first = await harness({ pool, sessions }) + await first.registry.pinSession(SessionId('kept')) + const pinned = [...first.registry.pinnedSessions] + await first.fiber.dispose() + + const second = await harness({ pool, sessions }) + // The stored entry survives verbatim, pinnedAt included. + expect(second.registry.pinnedSessions).toEqual(pinned) + }) +}) + +describe('registry-global session unpin', () => { + it('unpins durably in place and idempotently skips absent ids', async () => { + const dir = await makeDir('unpin-home') + const result = await harness({ + sessions: [header('one', dir, 100), header('two', dir, 200), header('three', dir, 300)], + }) + await result.registry.pinSession(SessionId('one')) + await result.registry.pinSession(SessionId('two')) + await result.registry.pinSession(SessionId('three')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['three', 'two', 'one']) + + await result.registry.unpinSession(SessionId('two')) + // Removal keeps the survivors in pin order. + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['three', 'one']) + expect(pinnedIds(storedState(result.pool).pinnedSessions)).toEqual(['three', 'one']) + const changesAfterFirst = result.changes.filter(change => change.table === '').length + + await result.registry.unpinSession(SessionId('two')) + expect(pinnedIds(result.registry.pinnedSessions)).toEqual(['three', 'one']) + // The absent-id repeat neither rewrites the medium nor emits a change. + expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst) + }) + + it('unpins an entry whose session is gone without consulting session persistence', async () => { + const dir = await makeDir('unpin-vanished') + const result = await harness({ sessions: [header('vanished', dir, 100)] }) + await result.registry.pinSession(SessionId('vanished')) + result.setSessions([]) + const listingsBefore = result.list.mock.calls.length + result.list.mockRejectedValueOnce(new Error('persistence backend down')) + + // Removing an id cannot introduce an unknown one, so the pin entry + // resolves even though no session backs it and no listing runs. + await expect(result.registry.unpinSession(SessionId('vanished'))).resolves.toBeUndefined() + expect(result.registry.pinnedSessions).toEqual([]) + expect(result.list.mock.calls.length).toBe(listingsBefore) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce532bc60a..370c1d9ed9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2184,9 +2184,6 @@ importers: '@deepseek-ai/dsh-client-ui-settings-plugins': specifier: workspace:^ version: link:../../client/ui-settings-plugins - '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions': - specifier: workspace:^ - version: link:../../client/ui-settings-unarchive-sessions '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar @@ -4262,57 +4259,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-unarchive-sessions: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller - '@deepseek-ai/dsh-api-workspace-controller': - specifier: workspace:^ - version: link:../../api/workspace-controller - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-renderer': - specifier: workspace:^ - version: link:../ui-renderer - '@deepseek-ai/dsh-client-ui-session': - specifier: workspace:^ - version: link:../ui-session - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../ui-workspace - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - packages/client/ui-sidebar: devDependencies: '@deepseek-ai/cordis': @@ -5247,9 +5193,15 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path + '@formkit/auto-animate': + specifier: 0.10.0 + version: 0.10.0 '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -14053,6 +14005,9 @@ packages: '@noble/hashes': optional: true + '@formkit/auto-animate@0.10.0': + resolution: {integrity: sha512-KGomRttjUfORuPUaR/ZGQw+6xfMrTM+sxnILv7JAd9AmabU9rg9i6gF/iC0Ih+QpKCubJpCA/1DX9UHKE8cX+A==} + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -21148,6 +21103,8 @@ snapshots: optionalDependencies: '@noble/hashes': 2.3.0 + '@formkit/auto-animate@0.10.0': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 33363b380e..4727164e95 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -131,7 +131,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-plugin-manager': { kind: 'none', reason: 'Browser-side management surface; registers nothing model-facing.' }, 'packages/client/ui-settings-plugin-inventory': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' }, - 'packages/client/ui-settings-unarchive-sessions': { kind: 'none', reason: 'Browser-side settings page; registers nothing model-facing.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/context/file-reference': { kind: 'indirect', reason: 'The discovery seam and grammar delegate model guidance to the composed provider.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 12075ca464..dfd585cbf7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -256,7 +256,6 @@ "@deepseek-ai/dsh-client-ui-settings-models": ["./packages/client/ui-settings-models/src"], "@deepseek-ai/dsh-client-ui-plugin-manager": ["./packages/client/ui-plugin-manager/src"], "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"], - "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": ["./packages/client/ui-settings-unarchive-sessions/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web/injections": ["./packages/client/web/src/apply-injections.ts"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b830d85b2f..08538a0e76 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -114,7 +114,6 @@ { "path": "./packages/client/ui-settings-models" }, { "path": "./packages/client/ui-plugin-manager" }, { "path": "./packages/client/ui-settings-plugin-inventory" }, - { "path": "./packages/client/ui-settings-unarchive-sessions" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/ui-renderer" }, { "path": "./packages/client/ui-session" }, From 3e96ed79a7ec21cdb0b92ca3f9dfe386a9494f17 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 15:37:03 +0800 Subject: [PATCH 02/12] fix(workspace): steady hovered session titles and polish the sidebar controls Hovering a session row no longer scrolls its clipped title (the mask fade stays), the one-time archived-filter callout and the content-search unavailable notice are gone, the group-row new-session button shares the new-chat glyph, and the view-options trigger uses the two-slider icon with a 200px menu floor and a visible separator hairline. --- .../tests/sidebar-title-hover-scroll.e2e.ts | 86 -------------- .../client/ui-primitives/src/Menu.module.css | 5 +- packages/client/ui-primitives/src/Menu.tsx | 9 +- .../client/ui-workspace/src/client/locales.ts | 2 - .../src/client/rows/Rows.module.css | 20 ---- .../ui-workspace/src/client/rows/Rows.tsx | 43 ++----- .../client/rows/WorkspaceBrowser.module.css | 55 +++------ .../src/client/rows/WorkspaceBrowser.tsx | 108 ++---------------- .../client/ui-workspace/src/client/stores.ts | 5 - .../tests/browser-styles.client.spec.ts | 6 +- .../ui-workspace/tests/rows.client.spec.tsx | 50 -------- .../ui-workspace/tests/tree.client.spec.ts | 7 -- .../tests/workspace-browser.client.spec.tsx | 11 +- 13 files changed, 46 insertions(+), 361 deletions(-) delete mode 100644 apps/web/tests/sidebar-title-hover-scroll.e2e.ts diff --git a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts deleted file mode 100644 index 2a0fe8caf4..0000000000 --- a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Web e2e scenario: hovering a sidebar session row reveals a title wider than -// its cell. The jsdom lane pins only the scroll position the handler writes; -// the assembled browser is where the title really clips, really reaches its own -// hovered extent (the trailing relative-time cell yields to the row menu while -// the pointer rests on the row, so the hovered cell is wider than the resting -// one), and really returns to its start when the pointer leaves. -// -// Zero model calls: seeding one renamed session and hovering its row touches no -// provider. -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' -import type { Browser, Page } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) -/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the reveal must reach. */ -const TITLE = 'Forked session clipped before its suffix (1)' - -describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) - const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-title-hover-scroll') - await workspace.attachSession(id) - await scaffold.ctx.sessionController.rename({ sessionId: id, title: TITLE }) - browser = await chromium.launch() - page = await newEnglishPage(browser) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it('reveals the clipped title under the pointer and restores its start after', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-title-hover-scroll')) - const row = page.getByRole('treeitem').filter({ has: page.getByText(TITLE, { exact: true }) }) - await row.waitFor({ timeout: 20_000 }) - const title = row.getByText(TITLE, { exact: true }) - - // At rest the row clips its title with an ellipsis and holds its start. - expect(await title.evaluate(el => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0) - expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') - expect(await title.evaluate(el => el.scrollLeft)).toBe(0) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('smooth') - - await row.hover() - // Poll against the live extent: the reveal must reach the far edge of the - // hovered cell (the wider one — the relative-time label yields to the row - // menu), whatever width the handler itself read. - await expect.poll( - async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), - { timeout: 10_000 }, - ).toBe(true) - // Scrolled to its end, the title must not paint the ellipsis over the - // characters the reveal reached. - expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('clip') - - // Leaving returns in one step: a smooth return would still be travelling - // inside this window, and it would carry the resting ellipsis back with it. - await page.mouse.move(0, 0) - await expect.poll( - async () => title.evaluate(el => el.scrollLeft), - { timeout: 150, interval: 20 }, - ).toBe(0) - expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') - - // The reduce override reaches the real stylesheet, so the reveal jumps - // instead of gliding. - await page.emulateMedia({ reducedMotion: 'reduce' }) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto') - await page.emulateMedia({ reducedMotion: null }) - expect(tripwire.pageErrors).toEqual([]) - }, 60_000) -}) 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 && (
{ e.stopPropagation(); onCreate() }} > - +
@@ -351,7 +327,7 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; // On archived rows the archived line already says the session is inactive, // so resting statuses (idle/completed) drop; live activity still shows. const statuses = sessionStatuses(node, t) - .filter(status => !(node.archived && status.state === 'done')) + .filter(status => !(node.archived && (status.state === 'done' || status.state === 'idle'))) return (
{displayTitle(node, t)}
@@ -491,7 +467,6 @@ export function SessionNodeItem({ const draggable = drag !== undefined && !row.blank && !row.archived const [menuOpen, setMenuOpen] = useState(false) const rowRef = useRef(null) - const titleRef = useRef(null) useEffect(() => { if (onReveal === undefined) return rowRef.current?.scrollIntoView({ block: 'nearest' }) @@ -504,8 +479,7 @@ export function SessionNodeItem({ ? [ { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, - // 20-native glyph in the menu's 16px icon slot (Menu.module.css .itemIcon). - { id: 'unarchive', label: t('menu.unarchiveSession'), icon: }, + { id: 'unarchive', label: t('menu.unarchiveSession'), icon: }, ] : [ { @@ -515,7 +489,7 @@ export function SessionNodeItem({ }, { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, - { id: 'archive', label: t('menu.archiveSession'), icon: }, + { id: 'archive', label: t('menu.archiveSession'), icon: }, ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = ( @@ -530,8 +504,6 @@ export function SessionNodeItem({ role="treeitem" aria-selected={selected} onClick={() => { onOpen(node.id) }} - onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }} - onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }} draggable={draggable} onDragStart={!draggable ? undefined @@ -568,7 +540,6 @@ export function SessionNodeItem({ )} { e.stopPropagation(); setMenuOpen(v => !v) }} > - + )} /> 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 87acb02c9a..f0b960dea8 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 { @@ -550,40 +556,17 @@ color: var(--dsw-alias-label-secondary); } -/* Post-archive callout bubble (tooltip visual): centered under the - view-options button, sliding inside the viewport when needed while the - arrow stays on the anchor. */ -.archiveHint { - position: fixed; - z-index: 100; - width: max-content; - max-width: 50vw; - padding: 3px 7px; - border-radius: 8px; - background: var(--dsw-alias-tooltip-bg); - color: var(--dsw-static-neutral-bluish-00); - font-size: 13px; - line-height: 20px; - transform: translateX(-50%); - pointer-events: none; - animation: archive-hint-in 150ms var(--ds-ease-in-out); -} - -/* Upward triangle riding the bubble's top edge; its inline `left` tracks the - anchor center when the bubble itself has been slid off-center. */ -.archiveHintArrow { - position: absolute; - top: -5px; - width: 0; - height: 0; - transform: translateX(-50%); - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-bottom: 5px solid var(--dsw-alias-tooltip-bg); -} - -@keyframes archive-hint-in { - from { opacity: 0; } +/* The design's ringed green check for the success toast; the Toast icon seat + defaults to the warning tint. */ +.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) { @@ -591,10 +574,6 @@ animation: none; } - .archiveHint { - animation: none; - } - .skeletonDot, .skeletonBar { animation: none; diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 9863762645..00d66ddfab 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -9,14 +9,15 @@ * menu in between; the flow and its error dialog live in WorkspacePicker * (same package — direct composition, no slot between them). */ -import { type CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import autoAnimate, { type AnimationController, type AutoAnimationPlugin } from '@formkit/auto-animate' import { - Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconChevronsUpDownOutlineRegular, - IconClockOutlineRegular, IconCloseFillRegular, IconFlatListOutlineRegular, IconFolderCloseRegular, - IconProjectAddOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, - IconWarningOutlineRegular, IconWorkspaceTreeOutlineRegular, Menu, Modal, Toast, Tooltip, + Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconCheckOutlineRegular, + 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, @@ -245,7 +246,7 @@ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrde { 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: 'show-archived', label: t('viewOptions.showArchived'), icon: }, { id: 'only-archived', label: t('viewOptions.onlyArchived'), icon: }, ]} selectedIds={[ @@ -265,6 +266,7 @@ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrde }} 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 @@ -285,79 +287,8 @@ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrde ) } -/** Hold before the archive callout dismisses itself. */ -const ARCHIVE_HINT_HOLD_MS = 3000 /** 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 -/** Minimum gap between the archive callout and the viewport edges. */ -const ARCHIVE_HINT_EDGE_MARGIN = 12 - -/** - * Post-archive callout bubble under the view-options button, naming the filter - * that reveals archived rows. Fixed positioning escapes the section header's - * overflow clipping without a portal (the Tooltip primitive's approach). - * The bubble centers on the anchor but slides back inside the viewport when - * the edges refuse it; the arrow stays on the anchor center either way. - * Dismissed by its hold timer or any pointer press on the page. - */ -function ArchiveFilterHint({ anchor, text, onDone }: { - anchor: HTMLElement | null - text: string - onDone: () => void -}) { - useEffect(() => { - const timer = setTimeout(onDone, ARCHIVE_HINT_HOLD_MS) - // Capture phase: a press anywhere dismisses, even when the target stops - // propagation (menus, rows). - const dismiss = (): void => { onDone() } - document.addEventListener('pointerdown', dismiss, true) - return () => { - clearTimeout(timer) - document.removeEventListener('pointerdown', dismiss, true) - } - }, [onDone]) - const bubble = useRef(null) - const [pos, setPos] = useState<{ x: number; y: number } | null>(null) - // Horizontal slide applied to keep the bubble inside the viewport; the - // arrow compensates by the same amount to keep pointing at the anchor. - const [dx, setDx] = useState(0) - useLayoutEffect(() => { - if (anchor === null) return - const measure = (): void => { - const rect = anchor.getBoundingClientRect() - setPos({ x: rect.left + rect.width / 2, y: rect.bottom + 8 }) - } - measure() - window.addEventListener('resize', measure) - return () => { window.removeEventListener('resize', measure) } - }, [anchor]) - useLayoutEffect(() => { - const el = bubble.current - if (el === null || pos === null) return - // Reset first so a larger viewport releases a previous slide. - el.style.left = `${String(pos.x)}px` - const r = el.getBoundingClientRect() - let slide = 0 - if (r.right > window.innerWidth - ARCHIVE_HINT_EDGE_MARGIN) { - slide = window.innerWidth - ARCHIVE_HINT_EDGE_MARGIN - r.right - } - if (r.left + slide < ARCHIVE_HINT_EDGE_MARGIN) slide = ARCHIVE_HINT_EDGE_MARGIN - r.left - el.style.left = `${String(pos.x + slide)}px` - setDx(slide) - }, [pos]) - if (pos === null) return null - return ( - - - {text} - - ) -} /** In-flight root-row drag: source identity plus the current insert marker. */ interface DragState { @@ -979,7 +910,6 @@ function SearchResults({ [list, workspaces, query, archivedSessionIds, archivedFilter, statuses, currentRemote, resultLimit], ) const pending = currentRemote.status === 'loading' - const failed = currentRemote.status === 'error' const currentId = panelActive ? undefined : Object.values(list.byId).find(session => (session.retainedBy.mainView ?? 0) > 0)?.id @@ -1079,11 +1009,7 @@ export function WorkspaceBrowser({ const archivedFilter = useStore(s => s.archivedFilter ?? 'default') const groupExpansion = useStore(s => s.groupExpansion) const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount) - const archiveHintSeen = useStore(s => s.archiveHintSeen) - // Archive callout under the view-options button; the seq keys remounts so - // consecutive archives restart the hold. const viewOptionsButton = useRef(null) - const [archiveHintSeq, setArchiveHintSeq] = useState(0) // Each bump opens the view-options menu (the archive toast's filter action). const [viewOptionsOpenSeq, setViewOptionsOpenSeq] = useState(0) // One transient banner at a time; the seq keys remounts so a repeat message restarts its hold. @@ -1389,14 +1315,6 @@ export function WorkspaceBrowser({ // same posture as reorder rejections. const onSessionArchive = (sessionId: SessionNode['id']) => { archiveSession(sessionId).then(() => { - // The first archive teaches the filter with the anchored callout; every - // later archive confirms with an actionable toast instead (undo the - // archive, or open the view-options menu on the filter items). - if (!archiveHintSeen) { - actions.markArchiveHintSeen() - setArchiveHintSeq(seq => seq + 1) - return - } setToast(current => ({ text: t('toast.archived'), seq: (current?.seq ?? 0) + 1, @@ -1779,20 +1697,12 @@ export function WorkspaceBrowser({ key={`toast-${String(toast.seq)}`} text={toast.text} {...toast.tone === 'success' - ? { tone: 'success' as const } + ? { icon: } : { icon: }} {...toast.actions === undefined ? {} : { actions: toast.actions, holdMs: ARCHIVE_TOAST_HOLD_MS }} onDone={() => { setToast(null) }} /> )} - {archiveHintSeq > 0 && ( - { setArchiveHintSeq(0) }} - /> - )}
) } diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index bd1ac63d87..d6855b5156 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -30,8 +30,6 @@ type WorkspaceViewState = { * fallback at the rehydration boundary. */ archivedFilter: ArchivedFilter - /** The one-time hint pointing at the view menu after the first archive has been shown. */ - archiveHintSeen: boolean } /** @@ -58,7 +56,6 @@ type WorkspaceViewActions = { initialOrders: Readonly>, ) => void setArchivedFilter: (draft: WorkspaceViewState, filter: ArchivedFilter) => void - markArchiveHintSeen: (draft: WorkspaceViewState) => void } /** Copy read-only projections into the persisted mutable store representation. */ @@ -80,7 +77,6 @@ export function createWorkspaceViewStore(): EngineStoreHandle { d.archivedFilter = filter }, - markArchiveHintSeen: (d) => { d.archiveHintSeen = true }, }, }) } diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 38d4ee36a3..df9938bd29 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -108,11 +108,7 @@ describe('WorkspaceBrowser.module.css list', () => { .toBe('var(--dsw-alias-interactive-bg-hover)') }) - it('reveals a clipped session title by scrolling it on row hover', () => { - // Smooth versus reduced motion is pinned as a computed style in - // apps/web/tests/sidebar-title-hover-scroll.e2e.ts: this helper merges - // same-selector rules across media queries, so the reduce override would - // mask the smooth declaration here. + it('fades a hovered title out through the mask instead of the resting ellipsis', () => { expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1') expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip') }) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index e8b5439d95..f9a4b2bcac 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -172,56 +172,6 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) - it('reveals a clipped session title by scrolling it while the row is hovered', () => { - const node: SessionNode = { - id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, - } - render( - , - ) - const row = screen.getByRole('treeitem') - const title = screen.getByText(node.title) - // jsdom lays out nothing: the title's clipped geometry is stated outright. - const geometry = (scrollWidth: number, clientWidth: number): void => { - Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true }) - Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true }) - } - - geometry(320, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(140) - fireEvent.pointerLeave(row) - expect(title.scrollLeft).toBe(0) - - // A title that fits has no scroll range: hovering leaves it at its start. - geometry(180, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(0) - }) - - it('returns a revealed title to its start in one step', () => { - const node: SessionNode = { - id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, - } - render( - , - ) - const row = screen.getByRole('treeitem') - const title = screen.getByText(node.title) - const scrollTo = vi.fn() - Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true }) - - fireEvent.pointerEnter(row) - fireEvent.pointerLeave(row) - // Browsers receive the explicit instant behavior that the stylesheet's - // smooth scroll-behavior would otherwise override. - expect(scrollTo).toHaveBeenCalledWith({ left: 0, behavior: 'instant' }) - }) - it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => { const onOpen = vi.fn() const node: SessionNode = { diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index c67d1b1c85..2c36b28f4d 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -667,13 +667,6 @@ describe('createWorkspaceViewStore', () => { expect(store.getSnapshot().sessionOrderByAccount).toEqual({}) }) - it('records the one-time archive hint', () => { - const store = createWorkspaceViewStore().create() - expect(store.getSnapshot().archiveHintSeen).toBe(false) - store.actions.markArchiveHintSeen() - expect(store.getSnapshot().archiveHintSeen).toBe(true) - }) - it('removes view state outside the retained Workspace key set', () => { const store = createWorkspaceViewStore().create() store.actions.setGroupExpanded('', true) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index b127ec558e..a099cfdca3 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -871,7 +871,7 @@ describe('WorkspaceBrowser', () => { }) }) - it('teaches the filter once, then confirms later archives with an undo/filter toast', async () => { + it('confirms every archive with an undo/filter toast', async () => { const archiveSession = vi.fn(async () => {}) const unarchiveSession = vi.fn(async () => {}) const b = mount({ @@ -886,14 +886,7 @@ describe('WorkspaceBrowser', () => { } const flush = () => act(async () => { await Promise.resolve() }) - // First archive: the anchored callout teaches the filter, no toast. - archive('one') - await flush() - expect(screen.getByText('已归档的会话可以通过筛选展示')).toBeTruthy() - expect(screen.queryByRole('alert')).toBeNull() - expect(b.store.getSnapshot().archiveHintSeen).toBe(true) - - // Later archives confirm with the actionable toast; a back-to-back + // Every archive confirms with the actionable toast; a back-to-back // archive replaces the still-showing toast. archive('two') await flush() From bd0461c894ea960d0b601aa780de95e07710fda6 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 15:45:42 +0800 Subject: [PATCH 03/12] fix(workspace): give the compact-label row spec the pin/archive props --- packages/client/ui-workspace/tests/rows.client.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index f9a4b2bcac..330b1e7cef 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -559,10 +559,10 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid(pendingInteraction), title: 'Needs input', blank: false, pendingInteraction, running: false, runningSubagentCount: 0, completed: false, - hasActiveSchedule: false, updatedAt: 0, + hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={tEn} />) const row = screen.getByRole('treeitem') expect(screen.getByText('Needs input').nextElementSibling?.textContent).toBe(compactLabel) expect(screen.getByText('Needs input').nextElementSibling?.getAttribute('aria-hidden')).toBe('true') From 5474efe890631e89c12067b6661618a47d325d5e Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 16:12:30 +0800 Subject: [PATCH 04/12] fix(client): lighten the ellipsis glyph and adopt the Toast success tone --- packages/client/ui-primitives/src/icons/index.tsx | 6 +++--- .../src/client/rows/WorkspaceBrowser.module.css | 13 ------------- .../src/client/rows/WorkspaceBrowser.tsx | 4 ++-- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4333bf43a6..e122d60363 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) => ( ) 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 f0b960dea8..930f492b94 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -556,19 +556,6 @@ color: var(--dsw-alias-label-secondary); } -/* The design's ringed green check for the success toast; the Toast icon seat - defaults to the warning tint. */ -.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) { .wide { animation: none; diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 00d66ddfab..5b611d800b 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -13,7 +13,7 @@ import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, us import clsx from 'clsx' import autoAnimate, { type AnimationController, type AutoAnimationPlugin } from '@formkit/auto-animate' import { - Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconCheckOutlineRegular, + Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular, IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, IconWarningOutlineRegular, @@ -1697,7 +1697,7 @@ export function WorkspaceBrowser({ key={`toast-${String(toast.seq)}`} text={toast.text} {...toast.tone === 'success' - ? { icon: } + ? { tone: 'success' as const } : { icon: }} {...toast.actions === undefined ? {} : { actions: toast.actions, holdMs: ARCHIVE_TOAST_HOLD_MS }} onDone={() => { setToast(null) }} From 94c62ea564c492c2c2873440606f46c0c6a0a959 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 16:56:01 +0800 Subject: [PATCH 05/12] fix(client): draw the sidebar new-chat glyph at the regular weight --- .../ui-sidebar/src/client/HeaderLeadingControls.tsx | 4 ++-- packages/client/ui-sidebar/src/client/SidebarRoot.tsx | 8 ++++++-- .../__snapshots__/sidebar-snapshot.client.spec.tsx.snap | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) 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" From 9ca19474ab337acc662ee8a0a22fe27d2fdf6bcb Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 17:34:45 +0800 Subject: [PATCH 06/12] fix(workspace): tooltip the row hover actions and open the sliders glyph --- .../client/ui-primitives/src/icons/index.tsx | 8 +- .../client/ui-workspace/src/client/locales.ts | 4 + .../ui-workspace/src/client/rows/Rows.tsx | 97 ++++++++++--------- 3 files changed, 60 insertions(+), 49 deletions(-) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index e122d60363..4f926c7e00 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -1559,10 +1559,10 @@ export const IconArchiveCheckOutlineMedium = (props: IconProps) => ( const IconSlidersTwoOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( ) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 7012e32b39..db48180ab3 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -64,6 +64,8 @@ export const zh = { 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', 'actions.session.aria': '会话“{name}”的操作', + 'actions.more': '更多操作', + 'actions.newSession': '新会话', 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', 'status.subagentsRunning.one': '{n} 个子代理运行中', @@ -152,6 +154,8 @@ export const en = { 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', 'actions.session.aria': 'Session actions for {name}', + 'actions.more': 'More actions', + '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/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index b4c924bdd9..b3e43d2ec3 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -12,7 +12,7 @@ import { HoverCard, IconAlarmClockOutlineRegular, IconArchiveOutlineRegular, IconBranchOutlineRegular, IconEditOutlineRegular, IconEllipsisOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, IconNewChatOutlineRegular, IconPinFillRegular, IconPinOutlineRegular, IconTrashOutlineRegular, - IconTriangleRightFillRegular, IconUnarchiveOutlineRegular, Menu, relativeTime, StateDot, + IconTriangleRightFillRegular, IconUnarchiveOutlineRegular, Menu, relativeTime, StateDot, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -176,25 +176,29 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog portal closeOnPointerLeave anchor={( - + + + )} /> )} - + + +
) @@ -581,41 +585,44 @@ export function SessionNodeItem({ portal closeOnPointerLeave anchor={( - + + + )} /> - {/* No tooltips on the hover actions: the row's hover card sits right - beside these buttons and the two bubbles collide; the actions - speak through their aria-labels and the resulting toasts. */} - - {/* Rightmost so it lands where the rest-state pin marker sits. */} - {!row.archived && ( + + + {/* Rightmost so it lands where the rest-state pin marker sits. */} + {!row.archived && ( + + + )} )} From 1d221f9723d4aad79517174b7fd094af5df22db5 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 18:16:16 +0800 Subject: [PATCH 07/12] fix(workspace): end-aligned action tooltips, slower hover cards, archived-row alignment --- .../ui-primitives/src/Tooltip.module.css | 10 +++ packages/client/ui-primitives/src/Tooltip.tsx | 12 +++- .../client/ui-workspace/src/client/locales.ts | 10 ++- .../ui-workspace/src/client/rows/Rows.tsx | 64 +++++++++---------- .../tests/host-home-staleness.client.spec.tsx | 4 +- .../ui-workspace/tests/rows.client.spec.tsx | 24 +++---- .../tests/workspace-browser.client.spec.tsx | 2 +- 7 files changed, 75 insertions(+), 51 deletions(-) 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 6f41eb7044..688a411fb9 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -43,6 +43,9 @@ if (typeof window !== 'undefined') { * 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). @@ -53,7 +56,7 @@ if (typeof window !== 'undefined') { * 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. @@ -154,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 = () => { @@ -195,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-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index db48180ab3..e721af231f 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -64,7 +64,10 @@ export const zh = { 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', 'actions.session.aria': '会话“{name}”的操作', - 'actions.more': '更多操作', + 'actions.archive': '归档会话', + 'actions.unarchive': '取消归档', + 'actions.pin': '置顶会话', + 'actions.unpin': '取消置顶', 'actions.newSession': '新会话', 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', @@ -154,7 +157,10 @@ export const en = { 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', 'actions.session.aria': 'Session actions for {name}', - 'actions.more': 'More actions', + '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', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index b3e43d2ec3..e291547031 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -176,20 +176,18 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog portal closeOnPointerLeave anchor={( - - - + )} /> )} - + + + + )} @@ -502,7 +503,7 @@ export function SessionNodeItem({ className={clsx( css.sessionRow, selected && css.selected, menuOpen && css.menuOpen, row.archived && css.archived, - flat && !showStatus && !row.archived && css.flatSessionRowWithoutStatus, + flat && !showStatus && css.flatSessionRowWithoutStatus, drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter, )} role="treeitem" @@ -585,19 +586,17 @@ export function SessionNodeItem({ portal closeOnPointerLeave anchor={( - - - + )} /> - + + , + ) + 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( diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index fb7a487585..ce6698be33 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -5,7 +5,7 @@ - button "Plugins" - text: Workspaces - button "Search sessions" -- textbox "Search sessions..." +- textbox "Search session names" - button "View options" - button "Add workspace" - tree "Sessions": diff --git a/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md b/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md index 14104cb63f..c01c19bc4b 100644 --- a/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md @@ -5,7 +5,7 @@ - button "插件" - text: 工作区 - button "搜索会话" -- textbox "搜索会话…" +- textbox "搜索会话名称" - button "视图选项" - button "添加工作区" - tree "会话": diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index 9f0c91f1c3..dee04dc9e8 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -5,7 +5,7 @@ - button "Plugins" - text: Workspaces - button "Search sessions" -- textbox "Search sessions..." +- textbox "Search session names" - button "View options" - button "Add workspace" - tree "Sessions": diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 4312288efa..c04c99e14e 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -14,7 +14,6 @@ - paragraph: partial - text: Stopped - button "Copy" -- tooltip "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation"