refactor(context-menu): inject shared trigger bindings through slots

This commit is contained in:
zhc
2026-10-28 17:08:06 +08:00
parent c89be0a88a
commit 49e9d42a00
225 changed files with 1092 additions and 1338 deletions
@@ -9,8 +9,8 @@
en: baf7f6e331fd9f03
zh: 8a45faaad34e6b0c
/agent-note-context-menu-contributions/decision:
en: 3a865968cfea5a86
zh: 8159892c54d16f2d
en: f04341778de57140
zh: 12147060ea28feb1
/agent-note-context-menu-contributions/alternatives-considered:
en: 1fc419cf3a0642d3
zh: f774e64679bed2ab
@@ -12,7 +12,9 @@ Session and tab menus already accept React Slot entries. Reusing only those entr
[Context menus](../../../../packages/client/ui-context-menu/README.md) own typed target families, contribution resolution, and callback lifetime. Feature plugins register actions through the same service available to external Client plugins. Plain presentation fields let a renderer consume resolved actions without loading contributed React components. Existing Session and tab Slots remain in feature-owned adapters, preserving those extension positions.
[File actions](../../../../packages/client/ui-file-actions/README.md) own original-content transfer separately from menus and image components. Saving retains original encoding; clipboard interoperability may require PNG conversion. A browser download supplies no local path, so reveal requires a verified Host file or an opaque reference returned by a native save.
[File actions](../../../../packages/client/ui-file-actions/README.md) own original-content transfer separately from menus and image components. Saving retains original encoding; clipboard interoperability may require PNG conversion. A browser download supplies no local path, so reveal requires a verified Host file or the destination returned by a native save.
The menu plugin contributes `useContextMenu` through the existing Slot root binding. Its ordinary `getTriggerProps` factory centralizes DOM gestures while business owners still provide captured targets. Plain children receive props or existing delegates; a separate React Context or utility package would duplicate the established lifetime and injection mechanism.
## Alternatives considered
@@ -12,7 +12,9 @@ Session 和标签页菜单已经支持 React Slot 条目。仅复用这些条目
[右键菜单](../../../../packages/client/ui-context-menu/README.zh.md)负责类型化目标类别、贡献解析和回调生命周期。功能插件通过与外部 Client 插件相同的服务注册动作。纯数据展示字段让渲染器无需加载贡献方的 React 组件即可消费已解析动作。现有 Session 和标签页 Slot 保留在各功能所属的适配器中,从而保留这些扩展位置。
[文件动作](../../../../packages/client/ui-file-actions/README.zh.md)独立于菜单和图片组件负责原始内容传输。保存保留原始编码;剪贴板互操作可能需要转换为 PNG。浏览器下载不提供本机路径,因此在文件夹显示要求已验证的 Host 文件,或原生保存返回的不透明引用。
[文件动作](../../../../packages/client/ui-file-actions/README.zh.md)独立于菜单和图片组件负责原始内容传输。保存保留原始编码;剪贴板互操作可能需要转换为 PNG。浏览器下载不提供本机路径,因此在文件夹显示要求已验证的 Host 文件,或原生保存返回的目标路径。
菜单插件通过现有 Slot root 绑定贡献 `useContextMenu`。普通函数 `getTriggerProps` 统一处理 DOM 手势,业务所有者继续提供捕获的目标。普通子组件通过 props 或现有 delegate 接收能力;独立 React Context 或工具包会重复已有生命周期与注入机制。
## 考虑过的替代方案
-1
View File
@@ -5037,7 +5037,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) |
| `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) |
| `@deepseek-ai/dsh-util-code-language` | — | [`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts) |
| `@deepseek-ai/dsh-util-context-menu` | — | [`packages/util/context-menu/src/index.ts`](../packages/util/context-menu/src/index.ts) |
| `@deepseek-ai/dsh-util-crypto` | — | [`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts) |
| `@deepseek-ai/dsh-util-file-drag` | — | [`packages/util/file-drag/src/index.ts`](../packages/util/file-drag/src/index.ts) |
| `@deepseek-ai/dsh-util-time` | — | [`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts) |
-1
View File
@@ -5039,7 +5039,6 @@ export interface Config {
| `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) |
| `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) |
| `@deepseek-ai/dsh-util-code-language` | — | [`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts) |
| `@deepseek-ai/dsh-util-context-menu` | — | [`packages/util/context-menu/src/index.ts`](../packages/util/context-menu/src/index.ts) |
| `@deepseek-ai/dsh-util-crypto` | — | [`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts) |
| `@deepseek-ai/dsh-util-file-drag` | — | [`packages/util/file-drag/src/index.ts`](../packages/util/file-drag/src/index.ts) |
| `@deepseek-ai/dsh-util-time` | — | [`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts) |
+2 -3
View File
@@ -24,7 +24,6 @@ flowchart TD
pkg_plugin_market_index["plugin-market-index"]
pkg_timeout["timeout"]
pkg_util_code_language["util-code-language"]
pkg_util_context_menu["util-context-menu"]
pkg_util_crypto["util-crypto"]
pkg_util_file_drag["util-file-drag"]
pkg_util_time["util-time"]
@@ -1464,6 +1463,7 @@ flowchart TD
pkg_client_test_runtime --> pkg_client_modules
pkg_client_test_runtime --> pkg_client_store
pkg_client_test_runtime --> pkg_client_ui_chat
pkg_client_test_runtime --> pkg_client_ui_context_menu
pkg_client_test_runtime --> pkg_client_ui_conversation
pkg_client_test_runtime --> pkg_client_ui_renderer
pkg_client_test_runtime --> pkg_client_ui_session
@@ -1517,7 +1517,6 @@ flowchart TD
| [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
| [`util-code-language`](../packages/util/code-language) | `util` | — |
| [`util-context-menu`](../packages/util/context-menu) | `util` | — |
| [`util-crypto`](../packages/util/crypto) | `util` | — |
| [`util-file-drag`](../packages/util/file-drag) | `util` | — |
| [`util-time`](../packages/util/time) | `util` | — |
@@ -1869,7 +1868,7 @@ flowchart TD
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
+2 -3
View File
@@ -26,7 +26,6 @@ flowchart TD
pkg_plugin_market_index["plugin-market-index"]
pkg_timeout["timeout"]
pkg_util_code_language["util-code-language"]
pkg_util_context_menu["util-context-menu"]
pkg_util_crypto["util-crypto"]
pkg_util_file_drag["util-file-drag"]
pkg_util_time["util-time"]
@@ -1466,6 +1465,7 @@ flowchart TD
pkg_client_test_runtime --> pkg_client_modules
pkg_client_test_runtime --> pkg_client_store
pkg_client_test_runtime --> pkg_client_ui_chat
pkg_client_test_runtime --> pkg_client_ui_context_menu
pkg_client_test_runtime --> pkg_client_ui_conversation
pkg_client_test_runtime --> pkg_client_ui_renderer
pkg_client_test_runtime --> pkg_client_ui_session
@@ -1519,7 +1519,6 @@ flowchart TD
| [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
| [`util-code-language`](../packages/util/code-language) | `util` | — |
| [`util-context-menu`](../packages/util/context-menu) | `util` | — |
| [`util-crypto`](../packages/util/crypto) | `util` | — |
| [`util-file-drag`](../packages/util/file-drag) | `util` | — |
| [`util-time`](../packages/util/time) | `util` | — |
@@ -1871,7 +1870,7 @@ flowchart TD
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
+2 -2
View File
@@ -9,8 +9,8 @@
en: f2d2c0d3b8f14ec7
zh: 441ed13ff3f81d2a
/web-client-slots/cardinality-and-scope:
en: 5b0ada069239576b
zh: 84e1e6b253d1461e
en: c53291211aad795c
zh: 6cda364644b5563a
/web-client-slots/component-inputs:
en: 9cb70abb2e39b958
zh: cd4b6f1705d90e17
+2
View File
@@ -65,6 +65,8 @@ The slot declaration fixes two independent axes.
`priority` is a shadowing rank for `single`, `list`, and `keyed` cells and an election order for `chain`. Lower values run or render first. Ordinary additive contributions should choose a fresh list `id` or keyed `key`; intentionally reusing a shipped cell replaces its presentation.
The [context-menu plugin](../../packages/client/ui-context-menu/README.md) contributes `useContextMenu` through `provideRoot`; every scope receives the same plugin-owned trigger factory. Its prop and retained handlers retire when the provider unloads.
## Component inputs
A registered component receives inputs assembled at its binding site. Components derive these types rather than copying their members.
+2
View File
@@ -67,6 +67,8 @@ Slot 声明固定两个相互独立的维度。
<a id="component-inputs"></a>
[右键菜单插件](../../packages/client/ui-context-menu/README.zh.md) 通过 `provideRoot` 贡献 `useContextMenu`;每个作用域接收同一个由插件拥有的触发器工厂。提供方卸载时,注入属性与保留的处理器一并失效。
## 组件输入
注册组件会在 binding 位置收到组装后的输入。组件应从这些类型推导 props,不要重新抄写成员。
@@ -9,8 +9,8 @@
en: b2018bf0250be579
zh: 1d8a243d09f4128f
/context-menu-services-in-custom-client-compositions/change:
en: deab94202290347c
zh: a8addf40b42fd471
en: 3eaef415166d076f
zh: 4040390d2572fc47
/context-menu-services-in-custom-client-compositions/migration:
en: 4364bdc909ddef3a
zh: f7d0e3e4b56a1d66
en: 77e0635c4ad577ca
zh: 4ee83b581c1a6e59
@@ -9,15 +9,15 @@ English | [中文](guide.zh.md)
## Change
GUI plugins now inject `menus` to open shared object menus. The open-in-app plugin also injects `fileActions`, `sidebarRight`, `sidebarRightTabs`, and `remote.workspaceFiles`. Custom compositions that mount these GUI plugins without the new services cannot activate them. The shipped Web application bundle includes the providers; existing Session and tab menu Slot names remain available.
GUI plugins now inject `contextMenus` to open shared object menus. The open-in-app plugin also injects `fileActions`, `sidebarRight`, `sidebarRightTabs`, and `remote.workspaceFiles`. Custom compositions that mount these GUI plugins without the new services cannot activate them. The shipped Web application bundle includes the providers; existing Session and tab menu Slot names remain available.
## Migration
1. In the package manifest that owns a custom composition, add dependencies on `@deepseek-ai/dsh-client-ui-context-menu` and `@deepseek-ai/dsh-client-ui-file-actions` at the same Harness version.
2. Add both package names as plugin rows in the custom Client composition alongside the existing locale, slots, shortcuts, sidebar, and workspace-file providers. The default `@deepseek-ai/dsh-web-app` bundle already contains these rows.
3. Update custom UI adapters for the changed published injected types, including `WorkspaceBrowserInjected.openSessionMenu`, its `menuTarget` hook, `ChatViewInjected.openContextMenu`, and the document/file-row menu callbacks. Bind the `menus` service in the plugin and pass the declared callbacks to components. Use the [menu reference](../../../../packages/client/ui-context-menu/README.md) for external action registration.
3. Rename `ctx.menus` to `ctx.contextMenus` and `MenuTargetMap` to `ContextMenuTargetMap`; neither old name has an alias. Mount the menu plugin so its `ctx.slots.provideRoot` contribution supplies `useContextMenu` to every Slot. Components call the hook once and spread `getTriggerProps(target, handlers?)` onto each element, including elements created in loops. See the [menu reference](../../../../packages/client/ui-context-menu/README.md) for handler composition and external action registration.
4. Rebuild and launch the custom profile. Confirm that GUI plugins activate, right-clicking a background Session preserves selection, and existing Session/tab Slot contributions still appear.
5. Custom Slot hosts must remove their declarations of `sidebar.workspaces.session.menu.item` and `sidebar.right.tab.menu.item`; the workspace and right-sidebar adapters now own these under `contextMenu.content`. Session rows retain the `[open, setOpen]` hook context, and tab rows receive `{ tab, dismiss }` within the clicked Session’s provider. A custom host replacing these adapters must declare the same children and supply those owners itself. Dockkit embedders using `openTabMenu` own the popup and dismissal; `renderTabMenuItems` is used only by the kit popup. Supply reactive `tabMenuOpen` to suppress close-button tooltips while a custom menu prepares or remains open.
5. Custom Slot hosts must remove their declarations of `sidebar.workspaces.session.menu.item` and `sidebar.right.tab.menu.item`; the workspace and right-sidebar adapters now own these under `contextMenu.content`. Session rows retain the `[open, setOpen]` hook context, and tab rows receive `{ tab, dismiss }` within the clicked Session’s provider. A custom host replacing these adapters must declare the same children and supply those owners itself. Dockkit embedders using `getTabMenuProps` own the popup and dismissal; `renderTabMenuItems` is used only by the kit popup. Supply reactive `tabMenuOpen` to suppress close-button tooltips while a custom menu prepares or remains open.
6. Built-in Session menu items are data contributions. The legacy React Slot retains external entries together before Archive; Slot priority no longer shadows built-in actions. Use `ctx.menus.register` for new actions and optional `commandId` to display an existing command’s current binding. Keep captured-target execution in `run`; no command dispatch is added.
7. Menu contributions use `icon?: ReactNode`; replace `symbol` with a glyph element and wrap icon URLs in `<img>`. Custom Chat flow and node adapters forward `openContextMenu` alongside `openFile`; tool views pass it as `ToolRow.onOpenContextMenu`. Reference chips no longer accept `filePath` or `fileBase`: capture that data in `openContextMenu`.
6. Built-in Session menu items are data contributions. The legacy React Slot retains external entries together before Archive; Slot priority no longer shadows built-in actions. Use `ctx.contextMenus.register` for new actions and optional `commandId` to display an existing command’s current binding. Keep captured-target execution in `run`; no command dispatch is added.
7. Menu contributions use `icon?: ReactNode`; replace `symbol` with a glyph element and wrap icon URLs in `<img>`. Custom Chat flow, node, and tool adapters forward `getContextMenuProps` alongside `openFile`; ToolRow uses the same prop. Chat resolves Session, path base, and line at the gesture, then captures the object. ReferenceChip and ImageLightbox receive `contextMenuProps`. Remove imports of `contextMenuTrigger` and `menuPoint`; their independent utility package is removed. Do not add gesture exports to ui-primitives.
@@ -9,15 +9,15 @@ description: "自定义 Client 组合必须提供 GUI 插件所需的右键菜
## 变更
GUI 插件现在注入 `menus` 以打开共享对象菜单。open-in-app 插件还注入 `fileActions`、`sidebarRight`、`sidebarRightTabs` 和 `remote.workspaceFiles`。自定义组合若挂载这些 GUI 插件却没有新服务,将无法激活它们。随附 Web 应用 bundle(插件包)已包含提供方;现有 Session 和标签页菜单 Slot 名称仍可使用。
GUI 插件现在注入 `contextMenus` 以打开共享对象菜单。open-in-app 插件还注入 `fileActions`、`sidebarRight`、`sidebarRightTabs` 和 `remote.workspaceFiles`。自定义组合若挂载这些 GUI 插件却没有新服务,将无法激活它们。随附 Web 应用 bundle(插件包)已包含提供方;现有 Session 和标签页菜单 Slot 名称仍可使用。
## 迁移
1. 在自定义组合所属的包清单中,添加与 Harness 版本一致的 `@deepseek-ai/dsh-client-ui-context-menu` 和 `@deepseek-ai/dsh-client-ui-file-actions` 依赖。
2. 在自定义 Client 组合中将两个包名添加为插件行,与已有 locale、slots、shortcuts、侧栏和工作区文件提供方并列。默认 `@deepseek-ai/dsh-web-app` bundle 已包含这些行。
3. 按变更后的公开注入类型更新自定义 UI 适配器,包括 `WorkspaceBrowserInjected.openSessionMenu` 及其 `menuTarget` hook、`ChatViewInjected.openContextMenu`,以及文档和文件行的菜单回调。在插件中绑定 `menus` 服务,并将声明的回调传给组件。外部动作注册参见[菜单参考](../../../../packages/client/ui-context-menu/README.zh.md)。
3. 将 `ctx.menus` 改为 `ctx.contextMenus`,将 `MenuTargetMap` 改为 `ContextMenuTargetMap`,旧名均无兼容别名。挂载菜单插件,由其 `ctx.slots.provideRoot` 贡献向每个 Slot 提供 `useContextMenu`。组件调用一次 hook,将 `getTriggerProps(target, handlers?)` 展开到每个元素,包括循环生成的元素。处理器组合和外部动作注册见[菜单参考](../../../../packages/client/ui-context-menu/README.zh.md)。
4. 重新构建并启动自定义 profile(配置档)。确认 GUI 插件正常激活、右键后台 Session 不改变选择,且现有 Session/标签页 Slot 贡献仍然显示。
5. 自定义 Slot 宿主应移除对 `sidebar.workspaces.session.menu.item` 和 `sidebar.right.tab.menu.item` 的声明;现在由工作区和右侧栏适配器在 `contextMenu.content` 下拥有它们。会话行继续接收 `[open, setOpen]` hook context,标签行在所点击会话的 provider 内接收 `{ tab, dismiss }`。替换这些适配器的自定义宿主须自行声明相同的子 Slot 并提供这些 owner。使用 `openTabMenu` 的 Dockkit 嵌入方负责弹窗和关闭,`renderTabMenuItems` 仅供组件自身的弹窗使用。通过响应式 `tabMenuOpen` 在自定义菜单准备或显示时禁用关闭按钮的 Tooltip。
5. 自定义 Slot 宿主应移除对 `sidebar.workspaces.session.menu.item` 和 `sidebar.right.tab.menu.item` 的声明;现在由工作区和右侧栏适配器在 `contextMenu.content` 下拥有它们。会话行继续接收 `[open, setOpen]` hook context,标签行在所点击会话的 provider 内接收 `{ tab, dismiss }`。替换这些适配器的自定义宿主须自行声明相同的子 Slot 并提供这些 owner。使用 `getTabMenuProps` 的 Dockkit 嵌入方负责弹窗和关闭,`renderTabMenuItems` 仅供组件自身的弹窗使用。通过响应式 `tabMenuOpen` 在自定义菜单准备或显示时禁用关闭按钮的 Tooltip。
6. 内置 Session 菜单项改为数据贡献。旧 React Slot 将外部条目集中显示在归档之前,Slot priority 不再覆盖内置动作。新增动作用 `ctx.menus.register`,可通过 `commandId` 展示已有命令的当前绑定。针对捕获目标的执行仍放在 `run`,不增加命令调度。
7. 菜单贡献使用 `icon?: ReactNode`:将 `symbol` 改为图标元素,并用 `<img>` 包装图标 URL。自定义 Chat 消息流和节点适配器需在 `openFile` 之外转发 `openContextMenu`,工具视图将其作为 `ToolRow.onOpenContextMenu` 传入。引用 chip 不再接受 `filePath` 或 `fileBase`:在 `openContextMenu` 回调中捕获这些数据。
6. 内置 Session 菜单项改为数据贡献。旧 React Slot 将外部条目集中显示在归档之前,Slot priority 不再覆盖内置动作。新增动作用 `ctx.contextMenus.register`,可通过 `commandId` 展示已有命令的当前绑定。针对捕获目标的执行仍放在 `run`,不增加命令调度。
7. 菜单贡献使用 `icon?: ReactNode`:将 `symbol` 改为图标元素,并用 `<img>` 包装图标 URL。自定义 Chat 消息流、节点和工具适配器在 `openFile` 之外转发 `getContextMenuProps`,ToolRow 使用同名 prop。Chat 在手势发生时解析 Session、路径基准和行号,再捕获对象。ReferenceChip 和 ImageLightbox 接收 `contextMenuProps`。删除 `contextMenuTrigger` 和 `menuPoint` 导入,其独立工具包已删除。不要向 ui-primitives 增加手势导出。
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
@@ -44,7 +45,7 @@ function mount(active = 'en') {
useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useWorkspaces: emptyWorkspaces(),
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
useStore: bindSnapshotSelector(store),
actions: store.actions,
t: (key: string) => key === 'language.title' ? 'Language' : key,
+1 -1
View File
@@ -82,7 +82,7 @@ function styleInjectionModule(
* Everything else under @deepseek-ai/* is either a module-table entry
* (external) or a leak the purity gate rejects.
*/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|official-bundles|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-(?:file-drag|context-menu)$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|official-bundles|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import { useState, type ComponentProps } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -29,7 +30,7 @@ function view(roster: Partial<AgentPresetSettingsState> = {}, inMenu = false) {
useAgentPresets: bindSnapshotSelector(agentPresets),
usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook,
useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook,
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
load, startCreatorDraft, onDismiss,
}
function TestMenu() {
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
@@ -23,7 +24,7 @@ function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?
...(startCreatorDraft === undefined ? {} : { startCreatorDraft }),
usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook,
useWorkspaces: unusedHook, useResource: unusedHook,
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
useAgentPresetSection: bindSnapshotSelector(store),
useDeveloperTools: bindSnapshotSelector(developerTools),
t: key => translations.get(key) ?? key }
+1 -2
View File
@@ -62,8 +62,7 @@
"clsx": "^2.0.0",
"@deepseek-ai/dsh-client-ui-file-drag": "workspace:*",
"@deepseek-ai/dsh-util-file-drag": "workspace:*",
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*",
"@deepseek-ai/dsh-util-context-menu": "workspace:*"
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*"
},
"files": [
"locale/*.json",
@@ -1,5 +1,4 @@
import { menuPoint } from '@deepseek-ai/dsh-util-context-menu'
import type { MenuTargetMap, MenuPoint } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import type { ContextMenuTargetMap, ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, DraggableImage, ImageLightbox, type FileDragActions } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -69,13 +68,13 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
* @returns the bounded thumbnail button, or the retry control on failure.
*/
export function MessageImage({ image, load, variant, labels, fileDrag, openImageMenu }: {
export function MessageImage({ image, load, variant, labels, fileDrag, getImageMenuProps }: {
image: MessageImageSpec
load: ImageLoader
variant: 'single' | 'tile' | 'thumbnail'
fileDrag: FileDragActions
labels: MessageImageLabels
openImageMenu?: ((source: MenuTargetMap['image'], point: MenuPoint) => void) | undefined
getImageMenuProps?: ((source: ContextMenuTargetMap['image']) => ContextMenuTriggerProps) | undefined
}) {
const preview = 'preview' in image ? image.preview : undefined
const attachment = 'attachment' in image ? image.attachment : undefined
@@ -138,11 +137,9 @@ export function MessageImage({ image, load, variant, labels, fileDrag, openImage
title={loadingThumbnail ? labels.loading : labels.open}
aria-label={loadingThumbnail ? labels.loading : labels.openNamed(label)}
aria-busy={loadingThumbnail || undefined}
onContextMenu={src === null || openImageMenu === undefined ? undefined : (event) => {
event.preventDefault(); event.stopPropagation()
openImageMenu({ url: src, name: label, ...(attachment === undefined ? {} : { mediaType: attachment.mediaType }) },
menuPoint(event))
}}
{...src === null ? {} : getImageMenuProps?.({
url: src, name: label, ...(attachment === undefined ? {} : { mediaType: attachment.mediaType }),
})}
onClick={() => { if (src !== null) setOpen(true) }}
>
{src === null
@@ -155,18 +152,16 @@ export function MessageImage({ image, load, variant, labels, fileDrag, openImage
style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close}
onContextMenu={openImageMenu === undefined ? undefined : (event) => {
event.preventDefault(); event.stopPropagation()
openImageMenu({ url: src, name: label, ...(attachment === undefined ? {} : { mediaType: attachment.mediaType }) },
menuPoint(event))
}} />}
contextMenuProps={getImageMenuProps?.({
url: src, name: label, ...(attachment === undefined ? {} : { mediaType: attachment.mediaType }),
})} />}
</>
)
}
/** Wrapping image group shared by user and assistant history: a lone image
* renders large unless its owner requests compact tiles or contained list thumbnails. */
export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels, fileDrag, openImageMenu }: {
export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels, fileDrag, getImageMenuProps }: {
images: readonly MessageImageSpec[]
load: ImageLoader
align: 'start' | 'end'
@@ -174,7 +169,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail =
thumbnail?: boolean
fileDrag: FileDragActions
labels: MessageImageLabels
openImageMenu?: ((source: MenuTargetMap['image'], point: MenuPoint) => void) | undefined
getImageMenuProps?: ((source: ContextMenuTargetMap['image']) => ContextMenuTriggerProps) | undefined
}) {
if (images.length === 0) return null
const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single'
@@ -188,7 +183,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail =
variant={variant}
fileDrag={fileDrag}
labels={labels}
openImageMenu={openImageMenu}
getImageMenuProps={getImageMenuProps}
/>
))}
</div>
@@ -12,11 +12,12 @@ type MessageImagesProps = MessageImagesOwnerProps & PropsRenderFactories
* @param props - image sources, authorized loader, layout hints, optional labels, and Factory locale seat.
* @returns the official gallery; no Session binding is needed by this component.
*/
export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag, openImageMenu }:
export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag, useContextMenu }:
FactoryComponentPropsOf<'conversation.images.content'>) {
const { getTriggerProps } = useContextMenu()
const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t])
return <ImageGallery images={images} load={loadImage} align={align}
compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} openImageMenu={openImageMenu} />
compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} getImageMenuProps={source => getTriggerProps({ kind: 'image', value: source })} />
}
/**
@@ -14,7 +14,7 @@ import { MessageImages, MessageImagesContent } from './MessageImages.tsx'
type MessageImagesInjected = SlotFactoryMap['conversation.images.content']['inject']
/** Slot registry required by the attachment presentation plugin. */
export const inject = ['slots', 'fileDrag', 'menus']
export const inject = ['slots', 'fileDrag']
/**
* Register composer attachments and the shared gallery with its domain adapters.
@@ -25,7 +25,6 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.images.content', scope: 'root', locale: 'conversation',
inject: (): MessageImagesInjected => ({
fileDrag: ctx.fileDrag.actions,
openImageMenu: (source, point) => { ctx.menus.open({ kind: 'image', value: source }, point) },
}),
}, MessageImagesContent)
ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
/** Independent domain image positions with one shared official gallery Factory. */
import * as fileDragPlugin from '@deepseek-ai/dsh-client-ui-file-drag/client'
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import { act, cleanup, within } from '@testing-library/react'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
@@ -29,7 +30,10 @@ async function bench() {
runtime.slots.installLocale(locale)
new UiConversation(runtime.ctx, runtime.sessions)
await runtime.mount(fileDragPlugin)
runtime.ctx.provide('menus', { open: vi.fn() } as never)
runtime.ctx.provide('contextMenus', { open: vi.fn() } as never)
runtime.slots.provideRoot({ props: {
useContextMenu: contextMenuHook((target, point) => { runtime.ctx.contextMenus.open(target, point) }),
} })
await runtime.mount(attachmentPlugin)
await runtime.sessions.add({ id: 'image-domains' })
const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-domains'))
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import { useState } from 'react'
import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -277,7 +278,10 @@ describe('ImageGallery', () => {
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined })
runtime.ctx.provide('menus', { open: vi.fn() } as never)
runtime.ctx.provide('contextMenus', { open: vi.fn() } as never)
runtime.slots.provideRoot({ props: {
useContextMenu: contextMenuHook((target, point) => { runtime.ctx.contextMenus.open(target, point) }),
} })
await runtime.mount(attachmentPlugin)
const images = [{ preview: { url: 'blob:labels', name: 'preview.png' } }]
const loadImage = vi.fn(async () => 'blob:unused')
@@ -293,13 +297,13 @@ describe('ImageGallery', () => {
const view = runtime.renderRoot()
const image = view.getByAltText('preview.png')
fireEvent.contextMenu(image, { clientX: 7, clientY: 8 })
expect(vi.spyOn(runtime.ctx.menus, 'open')).toHaveBeenCalledWith({ kind: 'image', value: { url: 'blob:labels', name: 'preview.png' } }, { x: 7, y: 8 })
expect(vi.spyOn(runtime.ctx.contextMenus, 'open')).toHaveBeenCalledWith({ kind: 'image', value: { url: 'blob:labels', name: 'preview.png' } }, { x: 7, y: 8 })
const frame = view.getByRole('button', { name: 'preview.png,点击查看原图' })
expect(frame.getAttribute('title')).toBe(labels.open)
fireEvent.click(frame)
expect(view.getByRole('dialog', { name: labels.lightbox.dialog })).toBeTruthy()
fireEvent.contextMenu(view.getByRole('dialog').querySelector('img')!, { clientX: 13, clientY: 14 })
expect(vi.spyOn(runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'image', value: { url: 'blob:labels', name: 'preview.png' } }, { x: 13, y: 14 })
expect(vi.spyOn(runtime.ctx.contextMenus, 'open')).toHaveBeenLastCalledWith({ kind: 'image', value: { url: 'blob:labels', name: 'preview.png' } }, { x: 13, y: 14 })
fireEvent.click(view.getByRole('button', { name: labels.lightbox.close }))
fireEvent.click(view.getByRole('button', { name: 'Default labels' }))
const defaultFrame = view.getByRole('button', { name: 'preview.png, click to view original' })
@@ -323,7 +327,10 @@ describe('ImageGallery', () => {
runtime.slots.installLocale(locale)
await runtime.declare({ 'conversation.chat.message.images': { kind: 'single', scope: 'session' } })
runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined })
runtime.ctx.provide('menus', { open: vi.fn() } as never)
runtime.ctx.provide('contextMenus', { open: vi.fn() } as never)
runtime.slots.provideRoot({ props: {
useContextMenu: contextMenuHook((target, point) => { runtime.ctx.contextMenus.open(target, point) }),
} })
await runtime.mount(attachmentPlugin)
await runtime.sessions.add({ id: 'message-images-test' })
const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test'))
@@ -337,7 +344,7 @@ describe('ImageGallery', () => {
expect(container.querySelector('[data-align="end"]')).not.toBeNull()
expect(loadImage).toHaveBeenCalledWith(attachment)
fireEvent.contextMenu(view.getByAltText('history.png'), { clientX: 9, clientY: 10 })
expect(vi.spyOn(runtime.ctx.menus, 'open')).toHaveBeenCalledWith({ kind: 'image', value: { url: 'blob:slot-image', name: 'history.png', mediaType: 'image/png' } }, { x: 9, y: 10 })
expect(vi.spyOn(runtime.ctx.contextMenus, 'open')).toHaveBeenCalledWith({ kind: 'image', value: { url: 'blob:slot-image', name: 'history.png', mediaType: 'image/png' } }, { x: 9, y: 10 })
expect(view.getByRole('button', { name: 'history.png,点击查看原图' }).getAttribute('data-variant')).toBe('single')
for (const [compact, thumbnail, variant] of [
[true, false, 'tile'],
@@ -358,7 +365,7 @@ const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: ()
it('opens image menus from the thumbnail and lightbox using the original source', async () => {
const openImageMenu = vi.fn()
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={vi.fn(async () => 'blob:original')}
variant="single" labels={labels} openImageMenu={openImageMenu} />)
variant="single" labels={labels} getImageMenuProps={contextMenuHook(openImageMenu)().getTriggerProps} />)
const image = await view.findByAltText('history.png')
fireEvent.contextMenu(image, { clientX: 18, clientY: 29 })
expect(openImageMenu).toHaveBeenLastCalledWith(expect.objectContaining({ url: 'blob:original', name: 'history.png' }), { x: 18, y: 29 })
@@ -21,7 +21,7 @@ async function bench() {
await ctx.fiber.dispose()
})
ctx.provide('sessions', sessions)
ctx.provide('menus', { open: vi.fn() } as never)
ctx.provide('contextMenus', { open: vi.fn() } as never)
ctx.provide('locale', new LocaleRuntime(ctx))
await ctx.plugin(SlotRegistry).await()
ctx.slots.register({
@@ -50,7 +50,7 @@ describe('attachment plugin', () => {
it('registers all entries and removes them with the plugin fiber', async () => {
const { ctx, fiber, register, registerFactory, readFileDrag } = await bench()
expect(inject).toEqual(['slots', 'fileDrag', 'menus'])
expect(inject).toEqual(['slots', 'fileDrag'])
expect(registerFactory.mock.calls[0]?.[0]).toMatchObject({
name: 'conversation.images.content', scope: 'root', locale: 'conversation',
})
@@ -46,9 +46,6 @@
},
{
"path": "../ui-context-menu"
},
{
"path": "../../util/context-menu"
}
]
}
+2 -2
View File
@@ -16,12 +16,12 @@
zh: 3172d1a2acbb27b7
/deepseek-ai-dsh-client-ui-chat/reference-previews:
en: 1333217883f84d52
zh: 4ad049b97fbc39dc
zh: 7b26db26cde15c1e
/deepseek-ai-dsh-client-ui-chat/hidden-chat-rows:
en: cbf1f0a2205046a5
zh: 2e59d0815351349f
/deepseek-ai-dsh-client-ui-chat/command-and-failure-rows:
en: aeeafe2f987fb899
en: 22e121f51bbfd863
zh: aab4c04d43520460
/deepseek-ai-dsh-client-ui-chat/turn-token-usage:
en: cfdfe3d99d3f4e09
+1 -1
View File
@@ -63,7 +63,7 @@ When an Assistant attempt retires without a visible message, Chat hides its alre
Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. Every terminal Turn failure renders its inline red-dot row; a quota failure's row states the neutral `message.failure.quota` copy instead of the provider message. The transient notice for a newly appended `QUOTA` or `ACCOUNT_QUOTA` comes from this package's frame-wide entry in `shell.overlay`, which outlives the Chat panel: it offers the one live notice to the `shell.quota-notice` chain and falls back to its own warning Toast, while an entry that claims the code replaces that fallback. Only Sessions this Client has bound and materialized publish; quota failures in Sessions it never opened do not. A newer notice replaces the current one unless a claiming entry retains it with `keepOpen()`: that call returns a release the caller owns and must run on unmount, any live hold keeps the claiming entry mounted and drops later notices, and releasing resumes later notices without replaying the dropped ones. The fallback Toast has no deferral of its own: while the Desktop account's opaque native Platform page covers the document, it still runs underneath and its display timer may elapse unseen, dismissing the notice itself, so only the persistent failure row remains. A release drops only its own hold, so one that runs after a dismissal or a newer hold leaves that newer hold intact. Dismissal and sign-out clear every hold, and dropped notices are not queued while their persistent failure rows still render. History replacement and pagination never publish a notice. Intermediate retries do not create a terminal row; output-token limits use the amber warning dot. A Turn that restart recovery closed as `interrupted` renders a localized stopped row stating that the DSH process exited mid-Turn, without a code chip, and its process summary reads Stopped.
Reference components declare their context-menu targets through the scoped Markdown delegate. Tool-file controls receive `openContextMenu` explicitly through the flow and node Slots. Both use the recorded path rather than a display label. Chat supplies Session and path context without interpreting plugin DOM attributes or undeclared links. Shift+F10 and the context key open the focused reference menu; images and editable controls keep their own handlers.
Reference components declare their context-menu targets through the scoped Markdown delegate. Tool-file controls receive `getContextMenuProps` explicitly through the flow and node Slots. Both use the recorded path rather than a display label. Chat supplies Session and path context without interpreting plugin DOM attributes or undeclared links. Shift+F10 and the context key open the focused reference menu; images and editable controls keep their own handlers.
-----
+1 -1
View File
@@ -37,7 +37,7 @@ kind: "package-reference"
已发送的普通文件附件保留草稿中的[文件卡片圆角](../../../docs/ui-radius.zh.md#radius-scale)。
引用组件通过带作用域的 Markdown delegate 声明右键目标。工具文件控件通过消息流和节点 Slot 显式接收 `openContextMenu`。两者均使用记录的路径,而不是显示标签。Chat 补齐 Session 与路径上下文,不解释插件 DOM 属性或未声明的链接。Shift+F10 和菜单键打开焦点引用的菜单;图片与可编辑控件保留自己的处理器。
引用组件通过带作用域的 Markdown delegate 声明右键目标。工具文件控件通过消息流和节点 Slot 显式接收 `getContextMenuProps`。两者均使用记录的路径,而不是显示标签。Chat 补齐 Session 与路径上下文,不解释插件 DOM 属性或未声明的链接。Shift+F10 和菜单键打开焦点引用的菜单;图片与可编辑控件保留自己的处理器。
原生消息图片使用独立的用户与输出位置。`conversation.chat.user.content` Factory 声明 `conversation.chat.user.images`,由 `UserContent` 渲染;Assistant 节点注册声明 `conversation.chat.message.images`,由 `AssistantMarkdown` 渲染。两者通过显式 props 接收图片来源、授权 `loadImage` 和布局参数。官方 `MessageImages` 条目调用共享的 root 作用域 `conversation.images.content` Factory。每个位置均可独立替换,无需复制画廊实现。
+1 -2
View File
@@ -99,8 +99,7 @@
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-client-ui-file-drag": "workspace:*",
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*",
"@deepseek-ai/dsh-util-context-menu": "workspace:*"
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~"
+7 -7
View File
@@ -77,7 +77,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = {
/** Services required by the Chat target and its presentation registrations. */
export const inject = [
'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale',
'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', 'fileDrag', 'menus',
'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', 'fileDrag',
]
/**
@@ -277,18 +277,18 @@ export function apply(ctx: Context): void {
else ctx.sidebarRight.openResource(url, { params: { line: options.line } })
await Promise.resolve()
},
openContextMenu: (target, point) => {
resolveContextMenuTarget: (target) => {
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
const current = session.projections.faceOf('workingDirectory').getSnapshot() as string | null | undefined
const path = (value: string) => resolveWorkspacePath(current ?? cwd, value)
switch (target.kind) {
case 'file': ctx.menus.open({ kind: 'file', value: { sessionId, path: target.base === 'workspace' ? resolveWorkspacePath(cwd, target.path) : path(target.path),
...(target.line === undefined ? {} : { line: target.line }) } }, point); break
case 'url': ctx.menus.open({ kind: 'url', value: { sessionId, url: target.url } }, point); break
case 'image': ctx.menus.open({ kind: 'image', value: { sessionId, url: target.url,
case 'file': return { kind: 'file', value: { sessionId, path: target.base === 'workspace' ? resolveWorkspacePath(cwd, target.path) : path(target.path),
...(target.line === undefined ? {} : { line: target.line }) } }
case 'url': return { kind: 'url', value: { sessionId, url: target.url } }
case 'image': return { kind: 'image', value: { sessionId, url: target.url,
...(target.name === undefined ? {} : { name: target.name }),
...(target.mediaType === undefined ? {} : { mediaType: target.mediaType }),
...(target.path === undefined ? {} : { path: path(target.path) }) } }, point); break
...(target.path === undefined ? {} : { path: path(target.path) }) } }
}
},
openExternalLink: (url) => {
@@ -8,7 +8,7 @@ type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace<P
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
export const AssistantNodeView = memo(function AssistantNodeView({
node, groupPart, useDisclosure, useGroupAction, useTurnData, turnProcess, openFile, loadImage, fileMentions,
node, groupPart, useDisclosure, useGroupAction, useTurnData, turnProcess, openFile, getContextMenuProps, loadImage, fileMentions,
usePresentation, renderSlot, t,
}: AssistantNodeViewProps) {
const data = node.data
@@ -19,8 +19,8 @@ export const AssistantNodeView = memo(function AssistantNodeView({
const owner = useMemo<TurnTailOwnerProps | undefined>(() => {
if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined
if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined
return { turn, seq: data.finalNode.seq, openFile }
}, [data.finalNode, openFile, tail, turn])
return { turn, seq: data.finalNode.seq, openFile, getContextMenuProps }
}, [data.finalNode, openFile, getContextMenuProps, tail, turn])
const mentions = useMemo(
() => owner === undefined ? undefined : fileMentions(owner),
[fileMentions, owner],
@@ -27,7 +27,7 @@ export const ChatFlow = memo(function ChatFlow({
entries, pendingInputs, lastInputTurn, deferCollapse,
useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, useConnectionState,
useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction,
cwd, openFile, openContextMenu, inspectCall, forkAt, loadImage, fileMentions,
cwd, openFile, getContextMenuProps, inspectCall, forkAt, loadImage, fileMentions,
}: ChatFlowSlotProps) {
const nodeStore = useChat(snapshot => snapshot.nodes)
const running = useSession(snapshot => snapshot.running)
@@ -47,7 +47,7 @@ export const ChatFlow = memo(function ChatFlow({
const seatProps = {
nodeStore, useChatNode, useChatNodeBottom, useChatNodeProcess, usePresentation,
useStore, actions, renderSlot, t, useGroupAction, deferCollapse,
cwd, openFile, openContextMenu, inspectCall, forkAt, loadImage, fileMentions,
cwd, openFile, getContextMenuProps, inspectCall, forkAt, loadImage, fileMentions,
}
const rows = entries.map((entry) => {
switch (entry.kind) {
@@ -49,7 +49,7 @@ function turnOf(node: ChatNode | undefined): number | undefined {
*/
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation,
cwd, openFile, openContextMenu, inspectCall, forkAt,
cwd, openFile, getContextMenuProps, inspectCall, forkAt,
loadImage, fileMentions, useStore, actions, renderSlot, t, useChatNodeBottom, deferCollapse, useGroupAction,
}: ChatNodeSeatProps) {
const bottom = useChatNodeBottom(nodeKey)
@@ -135,14 +135,14 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
...groupPart === undefined ? {} : { groupPart },
cwd,
openFile,
openContextMenu,
getContextMenuProps,
inspectCall,
forkAt,
loadImage,
fileMentions,
turnProcess,
}, [
node, groupPart, cwd, openFile, openContextMenu, inspectCall, forkAt,
node, groupPart, cwd, openFile, getContextMenuProps, inspectCall, forkAt,
loadImage, fileMentions, turnProcess,
])
if (routedNode === undefined || owner === null) return null
@@ -60,9 +60,13 @@ const MOTION_TAIL_MS = 800
*/
export function ChatView({
useSession, useChat, useConversation, useSessions, renderSlot,
sessionId, openFile, openExternalLink, openContextMenu, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
sessionId, openFile, openExternalLink, resolveContextMenuTarget, useContextMenu,
loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
usePresentation, useProjection, useInteractionIds, dispatchInteraction, t, fileDrag,
}: ChatViewSlotProps) {
const { getTriggerProps } = useContextMenu()
const getContextMenuProps = useCallback<NonNullable<import('@deepseek-ai/dsh-client-ui-primitives').MarkdownDelegate['getContextMenuProps']>>(
target => getTriggerProps(() => resolveContextMenuTarget(target)), [getTriggerProps, resolveContextMenuTarget])
const order = useChat(s => s.order)
const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries)
const entries = useMemo<readonly RenderEntry[]>(() => groupedEntries
@@ -231,14 +235,14 @@ export function ChatView({
</div>
)}
<MarkdownDelegateProvider
openContextMenu={openContextMenu}
getContextMenuProps={getContextMenuProps}
openFile={requestOpenFile}
openExternalLink={openExternalLink}
fileImages={fileImages}
>
{renderSlot('conversation.chat.flow', {
entries, pendingInputs, lastInputTurn, deferCollapse,
cwd, openFile: requestOpenFile, openContextMenu, inspectCall, forkAt, loadImage, fileMentions,
cwd, openFile: requestOpenFile, getContextMenuProps, inspectCall, forkAt, loadImage, fileMentions,
}, { hookContext: flowContext })}
</MarkdownDelegateProvider>
<div ref={interactionSurfaceRef} className={css.textInteraction} data-text-interaction-surface=""
@@ -26,7 +26,7 @@ function lastContent(snapshot: ChatSnapshot, turn: number, skipWarning: boolean)
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
export const TurnTailNodeView = memo(function TurnTailNodeView({
node, openFile, forkAt, renderSlot, t, useAccess, useChat, usePerformanceUsage,
node, openFile, getContextMenuProps, forkAt, renderSlot, t, useAccess, useChat, usePerformanceUsage,
}: TurnTailNodeViewProps) {
const detailed = usePerformanceUsage(mode => mode) === 'detailed'
const forkAllowed = useAccess('session/fork')
@@ -45,7 +45,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
: undefined
if (turn === undefined) return null
const closing = data.closing
const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile }
const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile, getContextMenuProps }
const tail = renderSlot('conversation.chat.turnTail', owner)
if (closing === null) return tail === null ? null : <div className={css.root} data-turn-tail={data.turn}>{tail}</div>
// Interruption-frozen partials carry no messageId, so they address no
@@ -60,6 +60,7 @@ export interface TurnTailOwnerProps {
turn: TurnLocation
seq: number
openFile: (path: string) => void
getContextMenuProps?: MarkdownDelegate['getContextMenuProps']
}
/** Owner currency of finalized-assistant actions. */
@@ -231,7 +232,7 @@ export interface ChatNodeOwnerProps {
cwd?: string | undefined
openFile: (path: string, options?: OpenFileOptions) => void
/** Object-menu callback bound to the originating Session and path base. */
openContextMenu?: MarkdownDelegate['openContextMenu']
getContextMenuProps?: MarkdownDelegate['getContextMenuProps']
inspectCall: ((callId: ToolCallId) => void) | undefined
forkAt: (seq: number) => void
/** Session-authorized image loader supplied by the Chat view. */
@@ -317,8 +318,8 @@ export interface PerformanceUsageInjected {
export interface ChatViewInjected {
/** File preparation and failure callbacks owned by the file-drag plugin. */
readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions
/** Open the captured reference without navigating or changing Session selection. */
openContextMenu: NonNullable<MarkdownDelegate['openContextMenu']>
/** Resolve the reference against this Session and its current path base when the gesture begins. */
resolveContextMenuTarget: (target: import('@deepseek-ai/dsh-client-ui-primitives').MarkdownMenuTarget) => import('@deepseek-ai/dsh-client-ui-context-menu/client').MenuTarget
/** Dispatch one gesture to the Conversation plugin registry. */
dispatchInteraction: (gesture: TextInteractionGesture) => string | null
hooks: {
@@ -362,7 +363,7 @@ export type ChatFlowDataInjected = Pick<ChatViewInjected, 'keyedHooks'> & {
/** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */
export interface ChatFlowOwnerProps extends Pick<ChatNodeOwnerProps,
'cwd' | 'openFile' | 'openContextMenu' | 'inspectCall' | 'forkAt' | 'loadImage' | 'fileMentions'> {
'cwd' | 'openFile' | 'getContextMenuProps' | 'inspectCall' | 'forkAt' | 'loadImage' | 'fileMentions'> {
readonly entries: readonly RenderEntry[]
readonly pendingInputs: readonly (PendingSubmission | InboxState['next-step'][number])[]
readonly lastInputTurn: number | undefined
@@ -317,28 +317,23 @@ describe('Chat inject API', () => {
it('resolves relative file paths against the Session\'s current working directory', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
vi.spyOn(b.runtime.ctx.menus, 'open')
vi.spyOn(b.runtime.ctx.contextMenus, 'open')
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w')
await injected.openFile('src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts')
injected.openContextMenu({ kind: 'file', path: 'src/a.ts', line: 7 }, { x: 1, y: 2 })
expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: {
expect(injected.resolveContextMenuTarget({ kind: 'file', path: 'src/a.ts', line: 7 })).toEqual({ kind: 'file', value: {
sessionId: ROOT, path: '/proj/.agents/worktrees/w/src/a.ts', line: 7,
} }, { x: 1, y: 2 })
injected.openContextMenu({ kind: 'file', path: 'src/a.ts', base: 'workspace' }, { x: 3, y: 4 })
expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: {
} })
expect(injected.resolveContextMenuTarget({ kind: 'file', path: 'src/a.ts', base: 'workspace' })).toEqual({ kind: 'file', value: {
sessionId: ROOT, path: '/proj/src/a.ts',
} }, { x: 3, y: 4 })
injected.openContextMenu({ kind: 'url', url: 'https://example.com/' }, { x: 5, y: 6 })
expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'url', value: { sessionId: ROOT, url: 'https://example.com/' } }, { x: 5, y: 6 })
injected.openContextMenu({ kind: 'image', url: 'blob:image', path: 'image.png', name: 'image.png', mediaType: 'image/png' }, { x: 7, y: 8 })
expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'image', value: {
} })
expect(injected.resolveContextMenuTarget({ kind: 'url', url: 'https://example.com/' })).toEqual({ kind: 'url', value: { sessionId: ROOT, url: 'https://example.com/' } })
expect(injected.resolveContextMenuTarget({ kind: 'image', url: 'blob:image', path: 'image.png', name: 'image.png', mediaType: 'image/png' })).toEqual({ kind: 'image', value: {
sessionId: ROOT, url: 'blob:image', path: '/proj/.agents/worktrees/w/image.png', name: 'image.png', mediaType: 'image/png',
} }, { x: 7, y: 8 })
} })
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', null)
injected.openContextMenu({ kind: 'image', url: 'https://example.com/image' }, { x: 9, y: 10 })
injected.openContextMenu({ kind: 'file', path: 'file.ts' }, { x: 9, y: 10 })
expect(vi.spyOn(b.runtime.ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: { sessionId: ROOT, path: '/proj/file.ts' } }, { x: 9, y: 10 })
expect(injected.resolveContextMenuTarget({ kind: 'image', url: 'https://example.com/image' })).toEqual({ kind: 'image', value: { sessionId: ROOT, url: 'https://example.com/image' } })
expect(injected.resolveContextMenuTarget({ kind: 'file', path: 'file.ts' })).toEqual({ kind: 'file', value: { sessionId: ROOT, path: '/proj/file.ts' } })
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w')
// A leading `./` names the same file, so the Sidebar reuses its tab.
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import type { ProcessGroupData } from '../src/client/contract/process-groups.ts'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
@@ -492,7 +493,7 @@ function makeHarness(
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
const props: HarnessStandardProps = {
fileDrag,
openContextMenu: vi.fn(),
resolveContextMenuTarget: vi.fn(() => ({ kind: 'image' as const, value: { url: 'unused' } })),
renderFactorySlot,
usePanelInfo: selector => selector({ activePanelId: null }),
sessionId: SID,
@@ -506,6 +507,7 @@ function makeHarness(
useTrajectory: (() => { throw new Error('unused') }),
useSessions: emptySessions(),
useSessionRetainInfo: () => undefined,
useContextMenu: contextMenuHook(),
useResource,
useSessionStatus: bindSnapshotSelector(
createSnapshotStore<SessionStatusSnapshot>(new Map()),
@@ -4716,5 +4718,5 @@ it('does not infer menu targets from links or data attributes inside a plugin fl
expect(fireEvent.contextMenu(view.getByRole('button', { name: 'Undeclared file' }))).toBe(true)
fireEvent.contextMenu(view.getByRole('button', { name: 'Custom target' }))
expect(custom).toHaveBeenCalledOnce()
expect(h.props.openContextMenu).not.toHaveBeenCalled()
expect(h.props.resolveContextMenuTarget).not.toHaveBeenCalled()
})
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { contextMenuTrigger } from '@deepseek-ai/dsh-util-context-menu'
import { MarkdownDelegateProvider, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
@@ -9,10 +9,10 @@ afterEach(cleanup)
it('leaves undeclared plugin links alone and binds only explicitly rendered Markdown targets', () => {
const open = vi.fn()
const custom = vi.fn()
const view = render(<MarkdownDelegateProvider openContextMenu={open} openFile={() => {}}>
const view = render(<MarkdownDelegateProvider getContextMenuProps={contextMenuHook(open)().getTriggerProps} openFile={() => {}}>
<a href="https://example.com/plugin">Plugin link</a>
<MarkdownText text="[File](docs/file.ts#L42)" labels={{ code: { copyLabel: 'Copy', copiedLabel: 'Copied' }, footnotes: 'Footnotes' }} />
<button {...contextMenuTrigger((point) => { custom({ kind: 'pluginDocument', id: 'one' }, point) })}>Plugin target</button>
<button {...contextMenuHook(custom)().getTriggerProps({ kind: 'pluginDocument', id: 'one' })}>Plugin target</button>
</MarkdownDelegateProvider>)
expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true)
expect(open).not.toHaveBeenCalled()
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -49,7 +50,7 @@ function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | ty
useWorkspaces: emptyWorkspaces(),
useSessionRetainInfo: () => undefined,
useResource,
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
useTranscriptView: bindSnapshotSelector(source),
useCollapseTiming: bindSnapshotSelector(collapseTiming),
useFoldedReplies: bindSnapshotSelector(foldedReplies),
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: 725b47765ee8f30b
/deepseek-ai-dsh-client-ui-context-menu/use-this-package:
en: 5d2cfa2a0ff18a18
zh: 634f233df5850a24
en: c84c65c7d24d51c3
zh: 98c8f40a326a8f0b
/deepseek-ai-dsh-client-ui-context-menu/use-this-package/register-actions:
en: 4807c2e0c057ab7d
zh: 9eda005d182dae28
en: 6ebc75b7209ac890
zh: 20f93e83bed67667
/deepseek-ai-dsh-client-ui-context-menu/understand-the-implementation:
en: 5b7717fad8457145
zh: 19a06fca8060cf62
+6 -6
View File
@@ -25,23 +25,23 @@ Add actions to right-click menus without importing a React component. Targets re
<a id="use-this-package"></a>
## Use this package
The Web application bundle mounts this Client plugin. External Client plugins declare the package in `dsh.client.inject`, import its `/client` types, and inject the `menus` service. `resolutionTimeoutMs` bounds each provider’s preparation (1000ms by default); timed-out providers are aborted and omitted with visible failure feedback.
The Web application bundle mounts this Client plugin. External Client plugins declare the package in `dsh.client.inject`, import its `/client` types, and inject the `contextMenus` service. `resolutionTimeoutMs` bounds each provider’s preparation (1000ms by default); timed-out providers are aborted and omitted with visible failure feedback.
Ordinary React controls bind `contextMenuTrigger(open)` from [`util/context-menu`](../../util/context-menu/README.md) to their actual DOM element. The callback supplies an explicit target to `ctx.menus.open()`; no DOM attributes are interpreted as target metadata. Nested controls can prevent handling, and portals bind at their rendered element. Special editors and canvases can call `open()` directly.
The plugin injects `useContextMenu` into every Slot scope through `ctx.slots.provideRoot`. Call `const { getTriggerProps } = useContextMenu()` once in the component and spread `getTriggerProps(target, handlers?)` onto each trigger. The factory is an ordinary function and can run in loops. It handles right-click, Shift+F10, the ContextMenu key, viewport coordinates, and focus without scrolling. Existing handlers run first; `preventDefault()` keeps their ownership and prevents duplicate handling by composed or nested bindings. Repeated, composing, and unrelated key events remain untouched. Pass `undefined` to retain native behavior, or a target resolver to read business state when the gesture begins. The resolved object is captured before focus changes. Plain children receive event props or a factory through props or an existing delegate; no additional React Context is needed. Special editors and canvases can call `ctx.contextMenus.open()` directly.
### Register actions
Call `ctx.effect(() => ctx.menus.register({ id, target, order, resolve }))` in the contributing plugin. `resolve` receives the captured target and its cancellation signal; return localized actions with stable IDs, optional icons, disabled state, submenus, and execution callbacks. Contributions sort by `order`, then ID. Duplicate contribution IDs within one target family fail registration; duplicate action paths reject that provider’s result. IDs are encoded by segment before publication.
Call `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))` in the contributing plugin. `resolve` receives the captured target and its cancellation signal; return localized actions with stable IDs, optional icons, disabled state, submenus, and execution callbacks. Contributions sort by `order`, then ID. Duplicate contribution IDs within one target family fail registration; duplicate action paths reject that provider’s result. IDs are encoded by segment before publication.
An action’s optional `commandId` associates it with an existing shortcut command. The overlay subscribes to `ctx.shortcuts.catalog`, including configured, removed, and reset bindings; nested and footer actions use the same presentation. This association never dispatches the command: selection invokes only the action’s captured `run` callback. The menu plugin requires the `shortcuts` service.
A contribution can declare `observe(target)` sources for permissions, membership, or localized labels. Source updates re-resolve that contribution against the same captured target and replace its callbacks; outdated asynchronous preparations are cancelled. Dismissal and plugin unload release every subscription.
Extend `MenuTargetMap` through declaration merging on `@deepseek-ai/dsh-client-ui-context-menu/client` for a new target family, then open it with `ctx.menus.open({ kind, value }, { x, y })`. Keep target values as immutable JSON-compatible identifiers and metadata. Execution callbacks capture that value; they must recheck mutable resources before acting. New registrations participate in the next opening. Unregistering removes visible actions and cancels pending metadata.
Extend `ContextMenuTargetMap` through declaration merging on `@deepseek-ai/dsh-client-ui-context-menu/client` for a new target family, then open it with `ctx.contextMenus.open({ kind, value }, { x, y })`. Keep target values as immutable JSON-compatible identifiers and metadata. Execution callbacks capture that value; they must recheck mutable resources before acting. New registrations participate in the next opening. Unregistering removes visible actions and cancels pending metadata.
`ctx.menus.resolve(target)` gives a separate occurrence for another renderer. Subscribe to its immutable action views, select a published ID, and dispose it when its owner closes. Views contain presentation fields only. Callbacks remain in the registry and execute at most once. Dismissal and replacement cancel metadata, but an action already selected owns its own execution and cancellation. Rejected providers and actions produce a persistent overlay toast; an action’s localized `failureLabel` supplies operation-specific recovery guidance.
`ctx.contextMenus.resolve(target)` gives a separate occurrence for another renderer. Subscribe to its immutable action views, select a published ID, and dispose it when its owner closes. Views contain presentation fields only. Callbacks remain in the registry and execute at most once. Dismissal and replacement cancel metadata, but an action already selected owns its own execution and cancellation. Rejected providers and actions produce a persistent overlay toast; an action’s localized `failureLabel` supplies operation-specific recovery guidance.
`ctx.menus.notify(text, success, action?)` can attach a localized text action through the shared Toast. The action provides `label`, `run`, and `failureLabel`; clicking dismisses the toast before execution, and rejection reports its failure label. A dismissed or replaced toast cannot execute its action again.
`ctx.contextMenus.notify(text, success, action?)` can attach a localized text action through the shared Toast. The action provides `label`, `run`, and `failureLabel`; clicking dismisses the toast before execution, and rejection reports its failure label. A dismissed or replaced toast cannot execute its action again.
Shared popups wait for every contribution to settle or time out before appearing. A repeated gesture within four CSS pixels closes the same target; a farther gesture replaces it with a separate fade. `ready` gives custom renderers the same preparation point. Actions pass `icon?: ReactNode` directly to the shared Menu. Contributing plugins create glyph elements or an `<img>` for application icons; icons are neither serialized nor mapped by the registry. Structured action icons align within each divider group; component contributions retain their own layout. Normal dismissal retains the popup for 80ms, replacement for 10ms; opening waits at least 20ms and fades in over 40ms. Reduced motion suppresses the fade.
+6 -6
View File
@@ -25,23 +25,23 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.client.inject` 声明本包,导入 `/client` 类型,并注入 `menus` 服务。`resolutionTimeoutMs` 限制每个提供方的准备时长(默认 1000ms);超时后取消并忽略该提供方,显示失败提示。
Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.client.inject` 声明本包,导入 `/client` 类型,并注入 `contextMenus` 服务。`resolutionTimeoutMs` 限制每个提供方的准备时长(默认 1000ms);超时后取消并忽略该提供方,显示失败提示。
普通 React 控件将 [`util/context-menu`](../../util/context-menu/README.zh.md) 的 `contextMenuTrigger(open)` 绑定到实际 DOM 元素。回调向 `ctx.menus.open()` 提供显式目标;DOM 属性不作为目标元数据解释。嵌套控件可以阻止处理,Portal 在实际渲染元素上绑定。特殊编辑器和 Canvas 可直接调用 `open()`。
插件通过 `ctx.slots.provideRoot` 向每个 Slot 作用域注入 `useContextMenu`。组件调用一次 `const { getTriggerProps } = useContextMenu()`,再将 `getTriggerProps(target, handlers?)` 展开到各个触发元素上。该工厂是普通函数,可以在循环中调用;它统一处理右键、Shift+F10、ContextMenu 键、视口坐标及无滚动聚焦。已有处理器先运行,`preventDefault()` 保留其处理权,并防止组合或嵌套绑定重复处理。重复、输入法组合及无关按键保持原状。传入 `undefined` 保留原生行为,也可传入目标解析函数,在手势开始时读取业务状态。解析后的对象会在焦点变化前捕获。普通子组件通过 props 或现有 delegate 接收事件 props 或工厂,无需额外 React Context。特殊编辑器和 Canvas 可直接调用 `ctx.contextMenus.open()`。
### 注册操作
在贡献插件中调用 `ctx.effect(() => ctx.menus.register({ id, target, order, resolve }))`。`resolve` 接收捕获的目标和取消信号;返回本地化操作,包含稳定 ID、可选图标、禁用状态、子菜单和执行回调。贡献项先按 `order`、再按 ID 排序。同类目标的贡献 ID 重复会导致注册失败;操作路径重复会拒绝该提供方的结果。发布前会逐段编码 ID。
在贡献插件中调用 `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))`。`resolve` 接收捕获的目标和取消信号;返回本地化操作,包含稳定 ID、可选图标、禁用状态、子菜单和执行回调。贡献项先按 `order`、再按 ID 排序。同类目标的贡献 ID 重复会导致注册失败;操作路径重复会拒绝该提供方的结果。发布前会逐段编码 ID。
动作可通过可选 `commandId` 关联已有快捷键命令。浮层订阅 `ctx.shortcuts.catalog`,跟随已配置、移除和重置的绑定更新;子菜单和底部动作采用相同展示。此关联不会调度命令:选择菜单项只调用该动作捕获的 `run` 回调。菜单插件依赖 `shortcuts` 服务。
贡献可通过 `observe(target)` 声明权限、成员状态或本地化标签的数据源。数据源更新时,针对同一捕获目标重新解析该贡献并替换回调;过期的异步准备会被取消。关闭菜单和卸载插件都会释放所有订阅。
通过对 `@deepseek-ai/dsh-client-ui-context-menu/client` 的声明合并扩展 `MenuTargetMap`,即可增加目标类别,再通过 `ctx.menus.open({ kind, value }, { x, y })` 打开。目标值使用不可变、可 JSON 序列化的标识与元数据。执行回调捕获该值,并在操作前重新检查可变资源。新注册项在下次打开时生效。注销会移除可见操作并取消待处理的元数据查询。
通过对 `@deepseek-ai/dsh-client-ui-context-menu/client` 的声明合并扩展 `ContextMenuTargetMap`,即可增加目标类别,再通过 `ctx.contextMenus.open({ kind, value }, { x, y })` 打开。目标值使用不可变、可 JSON 序列化的标识与元数据。执行回调捕获该值,并在操作前重新检查可变资源。新注册项在下次打开时生效。注销会移除可见操作并取消待处理的元数据查询。
`ctx.menus.resolve(target)` 为其他渲染器提供独立菜单实例。订阅不可变操作视图,选择已发布的 ID,并在所属界面关闭时释放。视图仅包含展示字段。回调保留在注册表中,最多执行一次。关闭和替换菜单会取消元数据查询,已选中操作则自行管理执行与取消。提供方或操作拒绝时由持久的覆盖层提示;操作的本地化 `failureLabel` 提供具体的恢复建议。
`ctx.contextMenus.resolve(target)` 为其他渲染器提供独立菜单实例。订阅不可变操作视图,选择已发布的 ID,并在所属界面关闭时释放。视图仅包含展示字段。回调保留在注册表中,最多执行一次。关闭和替换菜单会取消元数据查询,已选中操作则自行管理执行与取消。提供方或操作拒绝时由持久的覆盖层提示;操作的本地化 `failureLabel` 提供具体的恢复建议。
`ctx.menus.notify(text, success, action?)` 可通过共享 Toast 附加本地化文字操作。操作提供 `label`、`run` 和 `failureLabel`;点击后先关闭提示再执行,失败时显示对应文案。已关闭或被替换的提示不能再次执行其操作。
`ctx.contextMenus.notify(text, success, action?)` 可通过共享 Toast 附加本地化文字操作。操作提供 `label`、`run` 和 `failureLabel`;点击后先关闭提示再执行,失败时显示对应文案。已关闭或被替换的提示不能再次执行其操作。
共享弹窗等待全部贡献项完成或超时后才显示。同一目标在四个 CSS 像素以内重复触发会关闭菜单;更远的触发以独立淡入淡出替换菜单。`ready` 向自定义渲染器提供同一个准备完成时点。操作将 `icon?: ReactNode` 直接传给共享 Menu。贡献插件提供图标元素,或用 `<img>` 包装应用图标;registry 不序列化或映射图标。结构化操作的图标按分隔线组对齐;组件贡献保留自己的布局。普通关闭保留弹窗 80ms,替换保留 10ms;打开至少等待 20ms,再用 40ms 淡入。减少动态效果偏好会禁用淡入淡出。
@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-client-ui-layout": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-client-ui-dockkit": "workspace:*",
@@ -12,14 +12,20 @@ import { createMenuStore } from './store.ts'
import { dictionaries } from './locales.ts'
import { MenuOverlay } from './View.tsx'
import type { ContextMenus, MenuResolution, MenuTarget } from './types.ts'
import { createContextMenuBindings } from './trigger.ts'
export type { ContextMenus, MenuTarget, MenuTargetMap, MenuPoint, MenuAction, MenuActionView, MenuContribution, MenuResolution } from './types.ts'
export type { ContextMenus, MenuTarget, ContextMenuTargetMap, MenuPoint, MenuAction, MenuActionView, MenuContribution, MenuResolution } from './types.ts'
export type { ContextMenuBindings, ContextMenuTriggerProps, UseContextMenu } from './types.ts'
export { createMenuStore } from './store.ts'
declare module '@deepseek-ai/cordis' {
interface Context { /** Extensible context menus for explicit clicked objects. */ menus: ContextMenus }
interface Context { /** Extensible context menus for explicit clicked objects. */ contextMenus: ContextMenus }
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface GlobalStandardProps {
/** Menu bindings for explicit objects, available in every Slot scope. */
useContextMenu: import('./types.ts').UseContextMenu
}
interface SlotMap {
/** Optional React rows for existing extension positions; data contributions need no renderer. */
'contextMenu.content': { kind: 'keyed'; scope: 'root'; owner: { target: MenuTarget; dismiss: () => void } }
@@ -76,7 +82,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
closeTimer = setTimeout(() => { instance.actions.close() }, replacing ? MENU_TRANSITION_MS.replace : MENU_TRANSITION_MS.dismiss)
}
const close = (): void => { dismiss(false) }
const menus: ContextMenus = {
const contextMenus: ContextMenus = {
active: targetSource,
register: input => registry.register(input), resolve: target => registry.resolve(target), notify, close,
open: (target, point) => {
@@ -107,7 +113,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
},
}
ctx.provide('menus', menus)
ctx.provide('contextMenus', contextMenus)
const bindings = createContextMenuBindings((target, point) => { contextMenus.open(target, point) }, () => !disposed)
ctx.slots.provideRoot({ props: { useContextMenu: () => bindings } })
ctx.effect(() => {
const outside = (event: PointerEvent): void => {
if (event.button === 2 || (event.target instanceof Element && event.target.closest('[role="menu"], [data-context-menu-trigger]'))) return
@@ -121,20 +129,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
document.addEventListener('pointerdown', outside)
document.addEventListener('contextmenu', context)
const keyboard = (event: KeyboardEvent): void => {
escape(event)
if (event.defaultPrevented || event.repeat || event.isComposing || event.ctrlKey || event.altKey || event.metaKey
|| !(event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey)
|| !(event.target instanceof HTMLElement)) return
const gesture = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
if (!event.target.dispatchEvent(gesture)) { event.preventDefault(); event.stopImmediatePropagation() }
}
window.addEventListener('keydown', keyboard, true)
window.addEventListener('keydown', escape, true)
return () => {
disposed = true; cancelOpening(); clearTimeout(closeTimer); registry.dispose(); instance.actions.close()
document.removeEventListener('pointerdown', outside)
document.removeEventListener('contextmenu', context)
window.removeEventListener('keydown', keyboard, true)
window.removeEventListener('keydown', escape, true)
}
}, 'context menu: lifetime')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
@@ -1,12 +1,12 @@
/** Ordered menu resolution with cancellation and contribution-owned action lifetimes. */
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { MenuAction, MenuActionView, MenuContribution, MenuResolution, MenuTarget, MenuTargetMap } from './types.ts'
import type { MenuAction, MenuActionView, MenuContribution, MenuResolution, MenuTarget, ContextMenuTargetMap } from './types.ts'
interface Provider {
readonly id: string
readonly order: number
readonly kind: keyof MenuTargetMap
readonly kind: keyof ContextMenuTargetMap
readonly lifetime: AbortController
observe(target: MenuTarget): readonly ObservableSnapshot<unknown>[]
resolve(target: MenuTarget, signal: AbortSignal): readonly MenuAction[] | Promise<readonly MenuAction[]>
@@ -24,14 +24,14 @@ export class MenuRegistry {
* @param input - provider definition.
* @returns removal of this contribution and its pending resolutions.
*/
register<K extends keyof MenuTargetMap>(input: MenuContribution<K>): () => void {
register<K extends keyof ContextMenuTargetMap>(input: MenuContribution<K>): () => void {
if (this.disposed) throw new Error('Menu registry is disposed')
const key = `${encodeURIComponent(input.target)}/${encodeURIComponent(input.id)}`
if (this.providers.has(key)) throw new Error(`Duplicate menu contribution: ${key}`)
const provider: Provider = {
id: input.id, kind: input.target, order: input.order, lifetime: new AbortController(),
observe: target => input.observe?.(target.value as MenuTargetMap[K]) ?? [],
resolve: (target, signal) => input.resolve(target.value as MenuTargetMap[K], signal),
observe: target => input.observe?.(target.value as ContextMenuTargetMap[K]) ?? [],
resolve: (target, signal) => input.resolve(target.value as ContextMenuTargetMap[K], signal),
}
this.providers.set(key, provider)
return () => {
@@ -0,0 +1,43 @@
/** DOM gesture bindings owned by one menu plugin instance. */
import type { SyntheticEvent } from 'react'
import type { ContextMenuTriggerProps, MenuPoint, MenuTarget } from './types.ts'
/**
* Create stable bindings for the plugin's injected hook.
* @param open - Open against the supplied captured target and viewport position.
* @param active - Whether the plugin still owns its service.
* @returns Ordinary trigger-prop factory; no React subscription or context is created.
*/
export function createContextMenuBindings<Target extends object = MenuTarget>(
open: (target: Target, point: MenuPoint) => void, active: () => boolean,
): { getTriggerProps: (target: Target | (() => Target) | undefined, handlers?: ContextMenuTriggerProps) => ContextMenuTriggerProps } {
return {
getTriggerProps(target, handlers = {}) {
if (target === undefined) return handlers
const show = (event: SyntheticEvent<HTMLElement>, x: number, y: number): void => {
if (event.defaultPrevented || !active()) return
const captured = typeof target === 'function' ? target() : target
event.preventDefault()
event.stopPropagation()
event.currentTarget.focus({ preventScroll: true })
open(captured, { x, y })
}
return {
...handlers,
tabIndex: handlers.tabIndex ?? 0,
onContextMenu(event) {
handlers.onContextMenu?.(event)
const rect = event.clientX === 0 && event.clientY === 0 ? event.currentTarget.getBoundingClientRect() : undefined
show(event, rect?.left ?? event.clientX, rect?.bottom ?? event.clientY)
},
onKeyDown(event) {
handlers.onKeyDown?.(event)
if (event.defaultPrevented || event.repeat || event.nativeEvent.isComposing || event.ctrlKey || event.altKey || event.metaKey
|| !(event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey)) return
const rect = event.currentTarget.getBoundingClientRect()
show(event, rect.left, rect.bottom)
},
}
},
}
}
@@ -4,9 +4,27 @@ import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PaneId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
import type { HTMLAttributes } from 'react'
/** DOM handlers bound to an explicit object; existing handlers can prevent menu activation. */
export type ContextMenuTriggerProps = Pick<HTMLAttributes<HTMLElement>, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>
/** Functions supplied by the globally injected useContextMenu hook. */
export interface ContextMenuBindings {
/**
* Bind an object or deferred business target to one element. This is an ordinary function, safe in loops.
* @param target - Object captured when the gesture runs; undefined retains native behavior.
* @param handlers - Existing handlers run first and can cancel with preventDefault().
* @returns Combined pointer and keyboard handlers plus a keyboard focus stop.
*/
getTriggerProps: (target: MenuTarget | (() => MenuTarget) | undefined, handlers?: ContextMenuTriggerProps) => ContextMenuTriggerProps
}
/** Read menu gesture bindings owned by the current menu plugin lifetime. */
export type UseContextMenu = () => ContextMenuBindings
/** External plugins extend this map with their own target families. */
export interface MenuTargetMap {
export interface ContextMenuTargetMap {
session: { readonly sessionId: SessionId; readonly displayTitle: string }
file: { readonly sessionId: SessionId; readonly path: string; readonly line?: number; readonly paneId?: PaneId }
directory: { readonly sessionId: SessionId; readonly path: string }
@@ -22,7 +40,9 @@ export interface MenuTargetMap {
}
/** Captured target; changing selection never changes an open menu's target. */
export type MenuTarget = { [K in keyof MenuTargetMap]: { readonly kind: K; readonly value: MenuTargetMap[K] } }[keyof MenuTargetMap]
export type MenuTarget = {
[K in keyof ContextMenuTargetMap]: { readonly kind: K; readonly value: ContextMenuTargetMap[K] }
}[keyof ContextMenuTargetMap]
/** Pointer position in CSS pixels; keyboard gestures use the trigger's bottom edge. */
export interface MenuPoint { readonly x: number; readonly y: number }
/** Client presentation fields; callbacks stay in the registry. */
@@ -48,7 +68,7 @@ export interface MenuAction extends Omit<MenuActionView, 'children'> {
readonly run?: () => void | Promise<void>
}
/** A namespaced contribution, ordered within its target family. */
export interface MenuContribution<K extends keyof MenuTargetMap> {
export interface MenuContribution<K extends keyof ContextMenuTargetMap> {
readonly id: string
readonly target: K
readonly order: number
@@ -57,13 +77,13 @@ export interface MenuContribution<K extends keyof MenuTargetMap> {
* @param target - clicked object.
* @returns sources owned by the contributing feature; unsubscribed on dismissal or unload.
*/
observe?(target: MenuTargetMap[K]): readonly ObservableSnapshot<unknown>[]
observe?(target: ContextMenuTargetMap[K]): readonly ObservableSnapshot<unknown>[]
/**
* @param target - clicked object.
* @param signal - aborted on dismissal, plugin unload, or a newer observed preparation.
* @returns available actions.
*/
resolve(target: MenuTargetMap[K], signal: AbortSignal): readonly MenuAction[] | Promise<readonly MenuAction[]>
resolve(target: ContextMenuTargetMap[K], signal: AbortSignal): readonly MenuAction[] | Promise<readonly MenuAction[]>
}
/** Independent menu occurrence used by custom renderers and embedded menus. */
export interface MenuResolution {
@@ -88,7 +108,7 @@ export interface ContextMenus {
* @param contribution - target provider.
* @returns unregister.
*/
register<K extends keyof MenuTargetMap>(contribution: MenuContribution<K>): () => void
register<K extends keyof ContextMenuTargetMap>(contribution: MenuContribution<K>): () => void
/**
* Open the single shared popup.
* @param target - captured object.
@@ -11,7 +11,7 @@ import type { createMenuStore } from '../src/client/store.ts'
afterEach(() => { vi.useRealTimers() })
declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
interface MenuTargetMap { pluginDocument: { readonly id: string } }
interface ContextMenuTargetMap { pluginDocument: { readonly id: string } }
}
it('lets an external contribution open another menu and withdraws its actions when its fiber unloads', async () => {
@@ -19,7 +19,7 @@ it('lets an external contribution open another menu and withdraws its actions wh
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
let overlay!: MenuOverlayInjected
let store!: ReturnType<ReturnType<typeof createMenuStore>['create']>
ctx.provide('slots', {
ctx.provide('slots', { provideRoot: () => () => {},
inject: (_name: string, register: Parameters<SlotRegistry['inject']>[1]) => ctx.effect(register),
register: (entry: { inject: () => MenuOverlayInjected; store: ReturnType<typeof createMenuStore> }) => {
overlay = entry.inject(); store = entry.store.create(); return () => {}
@@ -30,59 +30,59 @@ it('lets an external contribution open another menu and withdraws its actions wh
await menuFiber.await()
expect(hostApply).not.toThrow()
const run = vi.fn()
const contribution = ctx.plugin({ inject: ['menus'], apply: (plugin: Context) => {
plugin.effect(() => plugin.menus.register({
const contribution = ctx.plugin({ inject: ['contextMenus'], apply: (plugin: Context) => {
plugin.effect(() => plugin.contextMenus.register({
target: 'pluginDocument', id: 'example.document', order: 0,
resolve: ({ id }) => [{ id: 'inspect', label: id, run: () => {
run(id)
plugin.menus.open({ kind: 'pluginDocument', value: { id: 'next' } }, { x: 3, y: 4 })
plugin.contextMenus.open({ kind: 'pluginDocument', value: { id: 'next' } }, { x: 3, y: 4 })
} }],
}))
} })
await contribution.await()
try {
ctx.menus.open({ kind: 'pluginDocument', value: { id: 'clicked' } }, { x: 1, y: 2 })
ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'clicked' } }, { x: 1, y: 2 })
await vi.waitFor(() => { expect(store.getSnapshot().items.length).toBeGreaterThan(0) })
overlay.select('example.document/inspect')
expect(run).toHaveBeenCalledExactlyOnceWith('clicked')
expect(ctx.menus.active.getSnapshot()).toEqual({ kind: 'pluginDocument', value: { id: 'next' } })
const occurrence = ctx.menus.resolve({ kind: 'pluginDocument', value: { id: 'retained' } })
expect(ctx.contextMenus.active.getSnapshot()).toEqual({ kind: 'pluginDocument', value: { id: 'next' } })
const occurrence = ctx.contextMenus.resolve({ kind: 'pluginDocument', value: { id: 'retained' } })
expect(occurrence.source.getSnapshot().map(action => action.label)).toEqual(['retained'])
await contribution.dispose()
expect(occurrence.source.getSnapshot()).toEqual([])
occurrence.select('example.document/inspect')
expect(run).toHaveBeenCalledOnce()
occurrence.dispose()
ctx.menus.notify('Saved', true)
ctx.contextMenus.notify('Saved', true)
expect(store.getSnapshot().notice).toMatchObject({ text: 'Saved', success: true })
ctx.menus.notify('Unavailable')
ctx.contextMenus.notify('Unavailable')
expect(store.getSnapshot().notice).toMatchObject({ text: 'Unavailable', success: false })
const reveal = vi.fn(async () => {})
ctx.menus.notify('Saved', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' })
ctx.contextMenus.notify('Saved', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' })
const previousAction = store.getSnapshot().notice!.action!
expect(previousAction.label).toBe('Show in folder')
previousAction.onClick()
previousAction.onClick()
expect(reveal).toHaveBeenCalledOnce()
expect(store.getSnapshot().notice).toBeNull()
ctx.menus.notify('Saved again', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' })
ctx.contextMenus.notify('Saved again', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' })
const replaced = store.getSnapshot().notice!.action!
ctx.menus.notify('New notice')
ctx.contextMenus.notify('New notice')
replaced.onClick()
expect(reveal).toHaveBeenCalledOnce()
for (const fail of [() => { throw new Error('denied') }, async () => { throw new Error('offline') }]) {
ctx.menus.notify('Saved', true, { label: 'Show in folder', run: fail, failureLabel: 'Reveal failed' })
ctx.contextMenus.notify('Saved', true, { label: 'Show in folder', run: fail, failureLabel: 'Reveal failed' })
store.getSnapshot().notice!.action!.onClick()
await vi.waitFor(() => { expect(store.getSnapshot().notice).toMatchObject({ text: 'Reveal failed', success: false }) })
}
const removeFailure = ctx.menus.register({ target: 'pluginDocument', id: 'failure', order: 0,
const removeFailure = ctx.contextMenus.register({ target: 'pluginDocument', id: 'failure', order: 0,
resolve: () => { throw new Error('Metadata unavailable') } })
ctx.menus.open({ kind: 'pluginDocument', value: { id: 'failed' } }, { x: 0, y: 0 })
ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'failed' } }, { x: 0, y: 0 })
expect(store.getSnapshot().notice?.text).toBe('failed')
removeFailure()
const removeAction = ctx.menus.register({ target: 'pluginDocument', id: 'action', order: 0,
const removeAction = ctx.contextMenus.register({ target: 'pluginDocument', id: 'action', order: 0,
resolve: () => [{ id: 'copy', label: 'Copy', failureLabel: 'Copy failed', run: () => { throw new Error('Denied') } }] })
ctx.menus.open({ kind: 'pluginDocument', value: { id: 'failed-action' } }, { x: 0, y: 0 })
ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'failed-action' } }, { x: 0, y: 0 })
overlay.select('action/copy')
expect(store.getSnapshot().notice?.text).toBe('Copy failed')
removeAction()
@@ -94,7 +94,7 @@ it('waits for metadata, toggles nearby repeated gestures, replaces occurrences,
const ctx = new Context()
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
let store!: ReturnType<ReturnType<typeof createMenuStore>['create']>
ctx.provide('slots', {
ctx.provide('slots', { provideRoot: () => () => {},
inject: (_name: string, register: Parameters<SlotRegistry['inject']>[1]) => ctx.effect(register),
register: (entry: { store: ReturnType<typeof createMenuStore> }) => { store = entry.store.create(); return () => {} },
} as never)
@@ -102,33 +102,33 @@ it('waits for metadata, toggles nearby repeated gestures, replaces occurrences,
const fiber = ctx.plugin({ inject, apply })
await fiber.await()
let finish!: (actions: readonly import('../src/client/types.ts').MenuAction[]) => void
ctx.menus.register({ target: 'pluginDocument', id: 'slow', order: 0,
ctx.contextMenus.register({ target: 'pluginDocument', id: 'slow', order: 0,
resolve: () => new Promise((resolve) => { finish = resolve }) })
const target = { kind: 'pluginDocument' as const, value: { id: 'file' } }
try {
ctx.menus.open(target, { x: 20, y: 30 })
ctx.contextMenus.open(target, { x: 20, y: 30 })
await vi.advanceTimersByTimeAsync(100)
expect(store.getSnapshot().target).toBeNull()
finish([{ id: 'open', label: 'Open' }])
await vi.advanceTimersByTimeAsync(0)
expect(store.getSnapshot().items.map(item => item.label)).toEqual(['Open'])
const first = store.getSnapshot().occurrence
ctx.menus.open(target, { x: 22, y: 31 })
expect(ctx.menus.active.getSnapshot()).toBeNull()
ctx.contextMenus.open(target, { x: 22, y: 31 })
expect(ctx.contextMenus.active.getSnapshot()).toBeNull()
expect(store.getSnapshot().closing).toBe(true)
await vi.advanceTimersByTimeAsync(80)
expect(store.getSnapshot().target).toBeNull()
ctx.menus.open(target, { x: 80, y: 30 })
ctx.contextMenus.open(target, { x: 80, y: 30 })
finish([{ id: 'second', label: 'Second' }])
await vi.advanceTimersByTimeAsync(40)
expect(store.getSnapshot().occurrence).toBe(first + 1)
ctx.menus.open(target, { x: 180, y: 30 })
ctx.contextMenus.open(target, { x: 180, y: 30 })
expect(store.getSnapshot().replacing).toBe(true)
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
finish([{ id: 'late', label: 'Late' }])
await vi.advanceTimersByTimeAsync(100)
expect(store.getSnapshot().target).toBeNull()
ctx.menus.open(target, { x: 20, y: 30 })
ctx.contextMenus.open(target, { x: 20, y: 30 })
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
finish([{ id: 'escape', label: 'Escape' }])
await vi.advanceTimersByTimeAsync(100)
@@ -141,76 +141,56 @@ it('keeps trigger focus through replacement and ignores non-dismissal events and
const ctx = new Context()
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
let overlay!: MenuOverlayInjected
ctx.provide('slots', {
ctx.provide('slots', { provideRoot: () => () => {},
inject: (_name: string, register: Parameters<SlotRegistry['inject']>[1]) => ctx.effect(register),
register: (entry: { inject: () => MenuOverlayInjected }) => { overlay = entry.inject(); return () => {} },
} as never)
ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
const fiber = ctx.plugin({ inject, apply })
await fiber.await()
const notify = ctx.menus.notify.bind(ctx.menus)
ctx.menus.register({ target: 'pluginDocument', id: 'plain', order: 0, resolve: () => [{ id: 'copy', label: 'Copy' }] })
const notify = ctx.contextMenus.notify.bind(ctx.contextMenus)
ctx.contextMenus.register({ target: 'pluginDocument', id: 'plain', order: 0, resolve: () => [{ id: 'copy', label: 'Copy' }] })
const trigger = document.createElement('button'), menu = document.createElement('button')
menu.setAttribute('data-context-menu', '')
menu.setAttribute('role', 'menu')
document.body.append(trigger, menu)
try {
trigger.focus()
ctx.menus.open({ kind: 'pluginDocument', value: { id: 'first' } }, { x: 10, y: 10 })
ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'first' } }, { x: 10, y: 10 })
await vi.advanceTimersByTimeAsync(40)
expect(overlay.returnFocus?.()).toBe(trigger)
menu.focus()
ctx.menus.open({ kind: 'pluginDocument', value: { id: 'second' } }, { x: 80, y: 80 })
ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'second' } }, { x: 80, y: 80 })
await vi.advanceTimersByTimeAsync(40)
expect(overlay.returnFocus?.()).toBe(trigger)
menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 2 }))
menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
expect(ctx.menus.active.getSnapshot()).not.toBeNull()
expect(ctx.contextMenus.active.getSnapshot()).not.toBeNull()
document.dispatchEvent(new MouseEvent('contextmenu', { cancelable: true }))
expect(ctx.menus.active.getSnapshot()).toBeNull()
expect(ctx.contextMenus.active.getSnapshot()).toBeNull()
const handled = new MouseEvent('contextmenu', { cancelable: true }); handled.preventDefault()
document.dispatchEvent(handled)
document.dispatchEvent(new Event('pointerdown'))
expect(ctx.menus.active.getSnapshot()).toBeNull()
expect(ctx.contextMenus.active.getSnapshot()).toBeNull()
const active = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null)
ctx.menus.open({ kind: 'pluginDocument', value: { id: 'third' } }, { x: 80, y: 80 })
ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'third' } }, { x: 80, y: 80 })
active.mockRestore()
expect(overlay.returnFocus?.()).toBeNull()
} finally { trigger.remove(); menu.remove(); await fiber.dispose() }
notify('late')
})
it('dispatches keyboard menus at focused triggers and leaves unhandled shortcuts available', async () => {
it('leaves unbound keyboard gestures native', async () => {
const ctx = new Context()
ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
ctx.provide('slots', { inject: () => () => {} } as never)
ctx.provide('slots', { provideRoot: () => () => {}, inject: () => () => {} } as never)
ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
const fiber = ctx.plugin({ inject, apply })
await fiber.await()
const trigger = document.createElement('button')
document.body.append(trigger)
const gesture = vi.fn((event: MouseEvent) => { event.preventDefault() })
trigger.addEventListener('contextmenu', gesture)
try {
for (const input of [{ key: 'ContextMenu' }, { key: 'F10', shiftKey: true }]) {
const key = new KeyboardEvent('keydown', { ...input, bubbles: true, cancelable: true })
trigger.dispatchEvent(key)
expect(key.defaultPrevented).toBe(true)
}
expect(gesture).toHaveBeenCalledTimes(2)
expect(gesture.mock.calls[0]?.[0]).toMatchObject({ clientX: 0, clientY: 0 })
for (const extra of [{ repeat: true }, { isComposing: true }, { ctrlKey: true }, { altKey: true }, { metaKey: true }]) {
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, ...extra }))
}
expect(gesture).toHaveBeenCalledTimes(2)
const prevented = new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, cancelable: true })
prevented.preventDefault(); trigger.dispatchEvent(prevented)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true }))
expect(gesture).toHaveBeenCalledTimes(2)
trigger.removeEventListener('contextmenu', gesture)
const unhandled = new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, cancelable: true })
trigger.dispatchEvent(unhandled)
expect(unhandled.defaultPrevented).toBe(false)
} finally { trigger.remove(); await fiber.dispose() }
const event = new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, cancelable: true })
document.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
} finally { await fiber.dispose() }
})
@@ -4,7 +4,7 @@ import { MenuRegistry } from '../src/client/registry.ts'
import type { MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client'
declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
interface MenuTargetMap { extension: { readonly id: string } }
interface ContextMenuTargetMap { extension: { readonly id: string } }
}
const target = { kind: 'extension' as const, value: { id: 'clicked' } }
@@ -3,7 +3,7 @@ import { menuTargetKey } from '../src/client/target-key.ts'
import type { MenuTarget } from '../src/client/types.ts'
declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
interface MenuTargetMap { nested: { id: string; data: { a: number; b: readonly (string | null)[] } } }
interface ContextMenuTargetMap { nested: { id: string; data: { a: number; b: readonly (string | null)[] } } }
}
it('compares JSON targets independently of object key insertion order while preserving arrays', () => {
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { act, cleanup, createEvent, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import { createPortal } from 'react-dom'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { apply, inject } from '../src/client/index.ts'
import { createContextMenuBindings } from '../src/client/trigger.ts'
import type { ContextMenuTriggerProps, MenuTarget } from '../src/client/types.ts'
afterEach(cleanup)
const target = (id: string): MenuTarget => ({ kind: 'session', value: { sessionId: SessionId(id), displayTitle: id } })
it('binds targets in loops, captures deferred identity before focus changes it, and composes existing handlers', () => {
const open = vi.fn(), previous = vi.fn(), clicked = vi.fn()
let selected = 'first'
const { getTriggerProps } = createContextMenuBindings(open, () => true)
const view = render(<>
{['one', 'two'].map(id => <button key={id} {...getTriggerProps(target(id), { onContextMenu: previous })}>{id}</button>)}
<button {...getTriggerProps(() => target(selected), { tabIndex: -1 })} onFocus={() => { selected = 'changed' }} onClick={clicked}>Captured</button>
</>)
for (const id of ['one', 'two']) {
fireEvent.contextMenu(view.getByRole('button', { name: id }), { clientX: 12, clientY: 23 })
expect(open).toHaveBeenLastCalledWith(target(id), { x: 12, y: 23 })
}
expect(previous).toHaveBeenCalledTimes(2)
const button = view.getByRole('button', { name: 'Captured' })
button.getBoundingClientRect = () => new DOMRect(30, 40, 50, 20)
const focus = vi.spyOn(button, 'focus')
fireEvent.keyDown(button, { key: 'F10', shiftKey: true })
expect(open).toHaveBeenLastCalledWith(target('first'), { x: 30, y: 60 })
expect(focus).toHaveBeenCalledWith({ preventScroll: true })
expect(button.tabIndex).toBe(-1)
expect(clicked).not.toHaveBeenCalled()
fireEvent.contextMenu(button)
expect(open).toHaveBeenLastCalledWith(target('changed'), { x: 30, y: 60 })
})
it('honors prevented and nested gestures, portals, repeats, composition, and missing targets', () => {
const open = vi.fn(), outer = vi.fn(), existing = vi.fn()
const bindings = createContextMenuBindings(open, () => true)
const parent = createContextMenuBindings(outer, () => true)
const inner = bindings.getTriggerProps(target('inner'))
const composed = bindings.getTriggerProps(target('second'), inner)
const view = render(<div {...parent.getTriggerProps(target('outer'))}>
<button {...composed}>Inner</button>
<button {...bindings.getTriggerProps(target('blocked'), {
onContextMenu: (event) => { event.preventDefault() }, onKeyDown: (event) => { event.preventDefault() },
})}>Blocked</button>
<button {...bindings.getTriggerProps(undefined, { onKeyDown: existing })}>Native</button>
{createPortal(<button {...inner}>Portal</button>, document.body)}
</div>)
const button = view.getByRole('button', { name: 'Inner' })
fireEvent.contextMenu(button, { clientX: 1, clientY: 2 })
fireEvent.keyDown(button, { key: 'ContextMenu' })
expect(open).toHaveBeenCalledTimes(2)
expect(open).toHaveBeenLastCalledWith(target('inner'), { x: 0, y: 0 })
fireEvent.contextMenu(view.getByRole('button', { name: 'Blocked' }))
fireEvent.keyDown(view.getByRole('button', { name: 'Blocked' }), { key: 'ContextMenu' })
for (const extra of [{ repeat: true }, { isComposing: true }, { ctrlKey: true }, { altKey: true }, { metaKey: true }, { key: 'Enter' }]) {
fireEvent.keyDown(button, { key: 'ContextMenu', ...extra })
}
const prevented = createEvent.contextMenu(button)
prevented.preventDefault(); fireEvent(button, prevented)
expect(open).toHaveBeenCalledTimes(2)
fireEvent.contextMenu(view.getByRole('button', { name: 'Portal' }), { clientX: 4, clientY: 5 })
expect(open).toHaveBeenCalledTimes(3)
expect(outer).not.toHaveBeenCalled()
fireEvent.keyDown(view.getByRole('button', { name: 'Native' }), { key: 'a' })
expect(existing).toHaveBeenCalledOnce()
})
it('injects the hook through the root Slot and withdraws both its prop and retained handlers on unload', async () => {
const runtime = await SlotTestRuntime.create()
onTestFinished(() => runtime.dispose())
runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
let retained: ContextMenuTriggerProps = {}
let received = false
await runtime.root.declare({}, ({ useContextMenu }) => {
received = typeof useContextMenu === 'function'
if (!received) return null
retained = useContextMenu().getTriggerProps(target('injected'))
return <button {...retained}>Injected</button>
})
const plugin = await runtime.mount({ inject, apply })
const view = runtime.renderRoot()
expect(received).toBe(true)
act(() => { fireEvent.contextMenu(view.getByRole('button')) })
expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(target('injected'))
const source = runtime.ctx.contextMenus.active
await plugin.dispose()
expect(received).toBe(false)
expect(source.getSnapshot()).toBeNull()
const detached = render(<button {...retained}>Retained</button>)
expect(fireEvent.contextMenu(detached.getByRole('button'))).toBe(true)
expect(fireEvent.keyDown(detached.getByRole('button'), { key: 'ContextMenu' })).toBe(true)
expect(source.getSnapshot()).toBeNull()
})
@@ -24,8 +24,8 @@
en: 08cf047cee6419bd
zh: 1f464f059753eacb
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: eccbd3c26d8e9cf0
zh: 177aa9498222c016
en: 3d738179dd8cd27e
zh: 07241272a043a0f4
/deepseek-ai-dsh-client-ui-conversation/model-experience:
en: e0a05b73ba3e4318
zh: dc85ab8433bb4456
+1 -1
View File
@@ -182,7 +182,7 @@ The selector must be a pure function of the owner currency. Its non-null return
`conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls and the context meter while preserving the editor and submit action. Closing the activity restores those controls with context details closed. The empty hero dock remains collapsed when it has no content. The occupant releases expansion on unmount and owns any activity-specific feedback.
Session title context gestures use the shared [context menu](../ui-context-menu/README.md) with the clicked breadcrumb’s Session. The reusable image gallery accepts optional injected `openImageMenu` support.
Session title context gestures use the shared [context menu](../ui-context-menu/README.md) with the clicked breadcrumb’s Session. The reusable image gallery accepts optional injected `getImageMenuProps` support.
<a id="model-experience"></a>
## Model Experience
+1 -1
View File
@@ -182,7 +182,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
`conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件,上下文详情保持关闭。首页输入框下方没有内容时,该区域保持收起。占用者在卸载时释放展开状态,并拥有活动专属反馈。
会话标题右键使用共享[右键菜单](../ui-context-menu/README.zh.md),目标是被点击面包屑所属会话。可复用图片库接受可选注入的 `openImageMenu` 支持。
会话标题右键使用共享[右键菜单](../ui-context-menu/README.zh.md),目标是被点击面包屑所属会话。可复用图片库接受可选注入的 `getImageMenuProps` 支持。
<a id="model-experience"></a>
## 模型体验
+1 -2
View File
@@ -99,8 +99,7 @@
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-message-quotes": "workspace:*",
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*",
"@deepseek-ai/dsh-util-context-menu": "workspace:*"
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*"
},
"files": [
"locale/*.json",
@@ -59,7 +59,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Services required by the Conversation plugin. */
export const inject = [
'menus',
'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'configForms',
]
@@ -452,7 +451,6 @@ export function apply(ctx: Context, config: Config = Config({})): void {
inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionHeaderInjected => ({
hooks: { conversationViews, entryPins: viewPins.unpinned, entryActions: viewActions },
setPinned: viewPins.setPinned,
openSessionMenu: (sessionId, displayTitle, point) => { ctx.menus.open({ kind: 'session', value: { sessionId, displayTitle } }, point) },
open: (id) => { workspaceNavigation.openSession(id) },
selectView: (view) => {
activateView(sessionId, view)
@@ -204,8 +204,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.images.content': {
scope: 'root'
props: MessageImagesContentInput
inject: { readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions
openImageMenu?(this: void, source: import('@deepseek-ai/dsh-client-ui-context-menu/client').MenuTargetMap['image'], point: { x: number; y: number }): void }
inject: { readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions }
locale: 'conversation'
}
}
@@ -477,7 +476,6 @@ export interface ConversationViewActionsOwnerProps {
/** Business callbacks injected into the strict Session header. */
export interface ConversationSessionHeaderInjected {
/** Open the clicked title's actions. */
openSessionMenu(sessionId: SessionId, displayTitle: string, point: { x: number; y: number }): void
/** Package-owned View roster source bound only for the Conversation header. */
readonly hooks: {
readonly conversationViews: ObservableSnapshot<readonly ViewTab[]>
@@ -1,4 +1,3 @@
import { menuPoint } from '@deepseek-ai/dsh-util-context-menu'
/** Strict per-session header/body content inserted into the resident conversation layout. */
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
@@ -108,8 +107,9 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
*/
export function ConversationSessionHeader({
sessionId, hideChrome, useSessions, useConversationViews, useStore,
renderSlot, open, openSessionMenu, selectView, setPinned, t, useEntryPins, useEntryActions,
renderSlot, open, useContextMenu, selectView, setPinned, t, useEntryPins, useEntryActions,
}: ConversationSessionHeaderProps) {
const { getTriggerProps } = useContextMenu()
const tabs = useConversationViews(value => value)
const selectedId = useStore(s => s.view)
const active = resolveActiveView(tabs, selectedId)
@@ -177,10 +177,7 @@ export function ConversationSessionHeader({
...last ? {} : { openTitle: () => { open(summary.id) } },
}
return (
<span key={summary.id} className={css.crumbSeg} onContextMenu={(event) => {
event.preventDefault(); event.stopPropagation()
openSessionMenu(summary.id, summary.displayTitle, menuPoint(event))
}}>
<span key={summary.id} className={css.crumbSeg} {...getTriggerProps({ kind: 'session', value: { sessionId: summary.id, displayTitle: summary.displayTitle } })}>
{index > 0 && <span className={css.crumbSep}>/</span>}
{lineage
? summary.subagent
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
@@ -46,7 +47,7 @@ function mount() {
useSessionRetainInfo: () => undefined,
useResource,
useWorkspaces: emptyWorkspaces(),
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
useBusyEnter: bindSnapshotSelector(policy.busyEnter),
setBusyEnter,
t: makeTranslate(en),
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
// InputBar behavior over the editor + submit-machine wiring: Enter-send
// semantics (IME guard, Shift newline, busy Enter policy, Ctrl/Meta steering,
// repeat suppression), running semantics (input stays free; continuable
@@ -194,7 +195,7 @@ function bench(over?: BenchOptions) {
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})),
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
(selector ?? (v => v))(key === 'plan'
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/**
* Impact-matrix projection tests (row by row): what each
* phase projects onto the InputBar — enter routing, visuals (command chip /
@@ -66,7 +67,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
useProjection: (() => undefined),
useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
useInput: bindSnapshotSelector(shell.state),
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/**
* Scenario-chain integration (scenarios A/C/D/H/I): the real per-session
* InputTriggerController pipeline over a real session scope (Client Sessions over
@@ -168,7 +169,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
useProjection: (() => undefined),
useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
useInput: bindSnapshotSelector(shell.state),
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/**
* QueueDock rendering and operations: authoritative rows, inline editing,
* collapse state, removal, QueueDock Steer, failure notices, and live retirement.
@@ -117,7 +118,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial<QueueDockInjected>
createSnapshotStore<SessionStatusSnapshot>(new Map()),
),
useWorkspaces: (() => { throw new Error('unused') }) as never,
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
useProjection: (() => undefined) as never,
useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'],
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createContext, useContext, type ReactNode } from 'react'
@@ -221,7 +222,7 @@ function mount(
}
if (key === 'conversation.session.header') {
return (
<ConversationSessionHeader openSessionMenu={vi.fn()}
<ConversationSessionHeader
hideChrome={(owner as Pick<ConversationSessionHeaderSlotProps, 'hideChrome'>).hideChrome}
sessionId={SID}
SessionProvider={({ children }) => children}
@@ -235,7 +236,7 @@ function mount(
useTrajectory={useTrajectory}
useSessions={props.useSessions}
usePanelInfo={props.usePanelInfo}
useResource={useResource}
useContextMenu={contextMenuHook()} useResource={useResource}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useWorkspaces={props.useWorkspaces}
@@ -265,7 +266,7 @@ function mount(
useTrajectory={useTrajectory}
useSessions={props.useSessions}
usePanelInfo={props.usePanelInfo}
useResource={useResource}
useContextMenu={contextMenuHook()} useResource={useResource}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useWorkspaces={props.useWorkspaces}
@@ -289,7 +290,7 @@ function mount(
<InputBar
sessionId={SID}
SessionProvider={({ children }) => children}
useResource={useResource}
useContextMenu={contextMenuHook()} useResource={useResource}
useSession={useSession}
useConversation={useConversation}
useSessions={props.useSessions}
@@ -353,6 +354,7 @@ function mount(
useTrajectory,
useSessions: bindSnapshotSelector(sessions),
usePanelInfo: selector => selector({ activePanelId: null }),
useContextMenu: contextMenuHook(),
useResource,
useSessionStatus,
useSessionRetainInfo: () => undefined,
@@ -386,6 +388,7 @@ function mount(
useSessions: bindSnapshotSelector(sessions),
useSessionStatus,
useSessionRetainInfo: () => undefined,
useContextMenu: contextMenuHook(),
useResource,
useWorkspaces: bindSnapshotSelector(workspaces),
...allowAllAccess,
@@ -107,9 +107,6 @@
},
{
"path": "../ui-context-menu"
},
{
"path": "../../util/context-menu"
}
],
"exclude": [
+1 -2
View File
@@ -82,8 +82,7 @@
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings": "workspace:*",
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*",
"@deepseek-ai/dsh-util-context-menu": "workspace:*"
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*"
},
"files": [
"locale/*.json",
@@ -21,7 +21,6 @@ const COLLAPSED_PRESENTED_COUNT = 4
/** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */
export interface DeliverablesInjected {
openFileMenu(sessionId: import('@deepseek-ai/dsh-session/types').SessionId, path: string, point: { x: number; y: number }): void
hooks: {
changesDiff: ObservableSnapshot<ReturnType<ChangesDiffStore['state']['getSnapshot']>>
showCodeDiff: ObservableSnapshot<boolean>
@@ -67,11 +66,12 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail
* @returns the closing turn's file rows.
*/
export function Deliverables({
matched, openFile, openFileMenu, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
matched, openFile, useContextMenu, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
useChangesDiff, loadChangesDiff, useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot,
}: Pick<TurnTailOwnerProps, 'openFile'> & {
matched: DeliverablesMatch
} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected> & PropsRenderSlots<'deliverables.file.actions'>) {
} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions' | 'useContextMenu'> & InjectFace<DeliverablesInjected> & PropsRenderSlots<'deliverables.file.actions'>) {
const { getTriggerProps } = useContextMenu()
const [expanded, setExpanded] = useState(false)
const showCodeDiff = useShowCodeDiff(value => value)
const cwd = useSessions(state => state.byId[sessionId]?.cwd)
@@ -110,7 +110,7 @@ export function Deliverables({
phase={states[presentedFileUrl(sessionId, file.seq, file.index)]}
host={host === 'error' ? null : host} t={t}
onPreview={() => { openFile(file.path) }}
onMenu={(point) => { openFileMenu(sessionId, file.path, point) }}
contextMenuProps={getTriggerProps({ kind: 'file', value: { sessionId, path: file.path } })}
actions={renderSlot('deliverables.file.actions', {
actionUrl: presentedFileUrl(sessionId, file.seq, file.index),
available: host !== null && host !== 'error' && host.available,
@@ -1,4 +1,3 @@
import { menuPoint } from '@deepseek-ai/dsh-util-context-menu'
/** File identity, Sidebar preview, and contributed native actions for one delivery. */
import type { ReactNode } from 'react'
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
@@ -20,13 +19,13 @@ function cardDescription(description: string | undefined, fallback: string): str
* @param props - durable file metadata, Sidebar preview, Host capabilities, gesture status, and localized copy.
* @returns the file card and its anchored action menu.
*/
export function PresentedFileCard({ file, cwd, phase, host, onPreview, onMenu, actions, t }: {
export function PresentedFileCard({ file, cwd, phase, host, onPreview, contextMenuProps, actions, t }: {
file: PresentedPath
cwd: string | undefined
phase: PresentedOpenPhase | undefined
host: PresentedHost | null
onPreview: () => void
onMenu: (point: { x: number; y: number }) => void
contextMenuProps: import('@deepseek-ai/dsh-client-ui-context-menu/client').ContextMenuTriggerProps
actions: ReactNode
} & PropsLocale<typeof NS>) {
const reveal = host?.fileManager ?? 'directory'
@@ -39,7 +38,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onMenu, a
return <div className={css.file} data-presented-file>
<button type="button" className={css.cardPreview} title={resolveWorkspacePath(cwd, file.path)}
aria-label={t('presented.previewCard', { name: file.path })} onClick={onPreview}
onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); onMenu(menuPoint(event)) }} />
{...contextMenuProps} />
<span className={css.fileIcon}><FileTypeIcon path={file.path} size={20} /></span>
<div className={css.fileBody}>
<div className={css.details}>
@@ -43,7 +43,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
/** Required services for the tail-slot and tab-type registrations and their dictionaries. */
export const inject = ['menus', 'slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight', 'configForms']
export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight', 'configForms']
/**
* Client plugin body: register the dictionaries, the turn-tail entry, and the comparison tab type.
@@ -69,7 +69,6 @@ export function apply(ctx: ClientContext): void {
locale: NS,
children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } },
inject: (): DeliverablesInjected => ({
openFileMenu: (sessionId, path, point) => { ctx.menus.open({ kind: 'file', value: { sessionId, path } }, point) },
hooks: { changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state,
showCodeDiff: ctx.configForms.developerTools.enabled },
loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index),
@@ -105,14 +104,14 @@ export function apply(ctx: ClientContext): void {
// The prose side of the same vocabulary: the chat view reaches this face
// via ctx.get, so its absence — this plugin composed out — is the off state.
const mentions: ChatFileMentions = {
forClosing(owner, sessionId) {
forClosing(owner) {
const paths = selectProducedFiles(owner)
const presented = presentedForClosing(owner)
if (paths === null && presented.length === 0) return undefined
return producedFileMentions([...new Set([...paths ?? [], ...presented.map(file => file.path)])], owner.openFile,
path => t('presented.previewButton', { name: path }),
owner.turn.data.get('deliverables')?.produced,
(path, point) => { ctx.menus.open({ kind: 'file', value: { sessionId, path } }, point) })
path => owner.getContextMenuProps?.({ kind: 'file', path }))
},
}
ctx.provide('chatFileMentions', mentions)
@@ -274,7 +274,7 @@ export { basename } from '../presented.ts'
* @param openFile - The chat view's file opener.
* @param label - Localizes the accessible open-label for a resolved path.
* @param operations - successful mutations whose literal arguments may name the same canonical targets.
* @param openMenu - optional context action for the resolved original path.
* @param getMenuProps - optional gesture bindings for the resolved original path.
* @returns The resolver MarkdownText consumes; the full path rides `title`,
* the same disambiguator the row's chips carry.
*/
@@ -283,7 +283,7 @@ export function producedFileMentions(
openFile: (path: string) => void,
label: (path: string) => string,
operations: readonly Pick<ProducedPath, 'path' | 'argumentPath'>[] = [],
openMenu?: (path: string, point: { x: number; y: number }) => void,
getMenuProps?: (path: string) => NonNullable<ReturnType<MarkdownFileMentions['resolve']>>['contextMenuProps'],
): MarkdownFileMentions {
return {
resolve(value) {
@@ -293,8 +293,9 @@ export function producedFileMentions(
}
const path = matches.size === 1 ? matches.values().next().value : undefined
if (path === undefined) return undefined
const contextMenuProps = getMenuProps?.(path)
return { open: () => { openFile(path) }, label: label(path), title: path,
...(openMenu === undefined ? {} : { openMenu: (point: { x: number; y: number }) => { openMenu(path, point) } }) }
...(contextMenuProps === undefined ? {} : { contextMenuProps }) }
},
}
}
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/**
* ui-deliverables browser half: the derivation contract of produced paths,
* recorded changes, and deliveries over engine-published Turn data, the
@@ -43,8 +44,9 @@ function openProps(controller = new PresentedOpenController(), summaries = new C
controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
const diffs = new ChangesDiffStore()
const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} }
const openMenu = vi.fn()
return {
openFileMenu: vi.fn(),
openMenu, useContextMenu: contextMenuHook(openMenu),
SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}</>,
renderSlot: renderFileActions,
useChangesDiff: <T,>(select: (state: ReturnType<typeof diffs.state.getSnapshot>) => T): T => select(diffs.state.getSnapshot()),
@@ -839,7 +841,7 @@ describe('producedFileMentions resolver', () => {
describe('plugin registration', () => {
it('registers the tail entry and fiber disposal removes it', async () => {
const ctx = new Context()
ctx.provide('menus', { register: () => () => {}, open: vi.fn() } as never)
ctx.provide('contextMenus', { register: () => () => {}, open: vi.fn() } as never)
await ctx.plugin(SlotRegistry).await()
new UiConversation(ctx, { binding: () => undefined } as never)
// The owning view's child declaration, stood up by a bench root entry.
@@ -908,20 +910,21 @@ describe('plugin registration', () => {
const preview = vi.fn<(path: string) => void>()
for (const produced of [[], [{ path: 'out/report.docx' }]]) {
const delivered = tailOwner({ produced, presented: [{ path: 'out/report.docx', seq: 2, index: 0 }] }, 3, preview)
delivered.getContextMenuProps = contextMenuHook<import('@deepseek-ai/dsh-client-ui-primitives').MarkdownMenuTarget>((target, point) => { if (target.kind === 'file') ctx.contextMenus.open({ kind: 'file', value: { sessionId: SessionId('child-session'), path: target.path } }, point) })().getTriggerProps
const mentions = service?.forClosing(delivered, SessionId('child-session'))
for (const text of ['report.docx', 'out/report.docx']) {
const mention = mentions?.resolve(text)
expect(mention?.label).toBe('Open out/report.docx in sidebar')
mention?.openMenu?.({ x: 41, y: 52 })
expect(vi.spyOn(ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'out/report.docx' } }, { x: 41, y: 52 })
const trigger = render(<button {...mention?.contextMenuProps}>Mention</button>)
fireEvent.contextMenu(trigger.getByRole('button'), { clientX: 41, clientY: 52 })
trigger.unmount()
expect(vi.spyOn(ctx.contextMenus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'out/report.docx' } }, { x: 41, y: 52 })
mention?.open()
}
}
expect(preview.mock.calls).toEqual(Array.from({ length: 4 }, () => ['out/report.docx']))
expect(fetcher).not.toHaveBeenCalled()
const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected
face.openFileMenu(SessionId('child-session'), 'report.png', { x: 53, y: 64 })
expect(vi.spyOn(ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'report.png' } }, { x: 53, y: 64 })
fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' }))
await face.reloadPresentedHost()
expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' })
@@ -952,8 +955,6 @@ describe('plugin registration', () => {
expect(fetcher).toHaveBeenCalledTimes(readsAfterHover)
expect(tabFace.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })
expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost)
face.openFileMenu(SessionId('child-session'), 'report.png', { x: 53, y: 64 })
expect(vi.spyOn(ctx.menus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'report.png' } }, { x: 53, y: 64 })
fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' }))
await tabFace.reloadPresentedHost()
fetcher.mockResolvedValueOnce(new Response(null, { status: 204 }))
@@ -1023,7 +1024,7 @@ describe('presented files', () => {
props.openPresented.mockResolvedValue({ kind: 'opened' })
const view = render(<Deliverables {...props} matched={matched} openFile={owner.openFile} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
fireEvent.contextMenu(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' }), { clientX: 18, clientY: 29 })
expect(props.openFileMenu).toHaveBeenCalledWith(SessionId('child-session'), 'report-0.docx', { x: 18, y: 29 })
expect(props.openMenu).toHaveBeenCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'report-0.docx' } }, { x: 18, y: 29 })
expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4)
const expand = view.getByRole('button', { name: 'Show all 8 delivered files' })
expect(expand.getAttribute('aria-expanded')).toBe('false')
@@ -2,21 +2,25 @@
/** Explicit file actions preserve their destination, availability, and independent failure state. */
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { contextMenuHook, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { PresentedFileCard } from '../src/client/PresentedFileCard.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const props = () => ({
cwd: undefined,
file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 },
host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const },
phase: undefined,
onPreview: vi.fn(),
onMenu: vi.fn(),
actions: <button type="button">Native file action</button>,
t: makeTranslate(en),
})
const props = () => {
const onMenu = vi.fn()
return {
cwd: undefined,
file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 },
host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const },
phase: undefined,
onPreview: vi.fn(),
onMenu, contextMenuProps: contextMenuHook((_target, point) => { onMenu(point) })()
.getTriggerProps({ kind: 'image', value: { url: 'unused' } }),
actions: <button type="button">Native file action</button>,
t: makeTranslate(en),
}
}
it('localizes reveal failures and restores the description when progress clears', () => {
const p = props()
const view = render(<PresentedFileCard {...p} phase="revealError" t={makeTranslate(zh)} />)
@@ -71,9 +71,6 @@
},
{
"path": "../ui-context-menu"
},
{
"path": "../../util/context-menu"
}
]
}
+1 -2
View File
@@ -29,8 +29,7 @@
"@testing-library/react": "^16.3.3",
"react-dom": "^18.2.0",
"react": "^18.2.0",
"clsx": "^2.0.0",
"@deepseek-ai/dsh-util-context-menu": "workspace:*"
"clsx": "^2.0.0"
},
"files": [
"lib/index.js",
@@ -67,8 +67,8 @@ export interface DockSurfaceProps {
/** Whether the embedder popup is preparing or visible; suppresses tab tooltips. */
readonly tabMenuOpen?: boolean | undefined
/** Override the kit popup; the embedder owns rendering and dismissal. */
readonly openTabMenu?: ((tab: TabRecord, paneId: PaneId, point: { x: number; y: number }) => void) | undefined
/** Extra rows for the kit popup; ignored when openTabMenu is supplied. */
readonly getTabMenuProps?: ((tab: TabRecord, paneId: PaneId, handlers?: Pick<import('react').HTMLAttributes<HTMLElement>, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) => Pick<import('react').HTMLAttributes<HTMLElement>, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) | undefined
/** Extra rows for the kit popup; ignored when getTabMenuProps is supplied. */
readonly renderTabMenuItems?: TabMenuExtras
/**
* Surface-wide controls, drawn at the far end of the top-right pane's tab
@@ -175,7 +175,7 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean {
/** The split tree and the gestures over it. */
function Surface({
state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems,
openTabMenu, tabMenuOpen, chrome, onRoom,
getTabMenuProps, tabMenuOpen, chrome, onRoom,
draw,
dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false,
}: DockSurfaceProps & { readonly draw: (callbacks: PaneCallbacks, preview: SizePreview | undefined) => ReactNode }): ReactNode {
@@ -297,7 +297,7 @@ function Surface({
renderTab,
renderTabTitle,
renderTabMenuItems,
openTabMenu, tabMenuOpen,
getTabMenuProps, tabMenuOpen,
chromePaneId: topRightPaneId(state),
chrome,
}
@@ -1,4 +1,3 @@
import { menuPoint } from '@deepseek-ai/dsh-util-context-menu'
/**
* One pane: its tab strip (drag source, drop target, split control) and the
* active tab's body with the dock preview overlay. Presentational; every gesture
@@ -304,6 +303,13 @@ export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): Re
// A pane's lone unclosable chip is a label, not a choice: there is
// no other tab to select against and nothing to do to it.
const quiet = !closable && pane.tabs.length === 1
const onKeyDown = (event: import('react').KeyboardEvent<HTMLElement>) => {
if (event.target !== event.currentTarget) return
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return
const next = chipToFocus(event.key, pane.tabs, tabId)
if (next !== undefined) { event.preventDefault(); focusChip(next); return }
if (selects(event.key)) { event.preventDefault(); activate(tabId) }
}
return (
<Fragment key={tabId}>
{(index > 0 || stripIndex === index) && (
@@ -351,30 +357,13 @@ export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): Re
event.stopPropagation()
activate(tabId)
}}
onKeyDown={(event) => {
// Keys on the chip's nested close control are that control's.
if (event.target !== event.currentTarget) return
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return
const next = chipToFocus(event.key, pane.tabs, tabId)
if (next !== undefined) {
event.preventDefault()
focusChip(next)
return
}
if (selects(event.key)) {
event.preventDefault()
activate(tabId)
}
}}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
if (callbacks.openTabMenu !== undefined) {
callbacks.openTabMenu(tab, pane.id, menuPoint(event))
return
}
const anchor = event.currentTarget
setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor })
{...callbacks.getTabMenuProps?.(tab, pane.id, { onKeyDown, tabIndex: selected ? 0 : -1 }) ?? {
onKeyDown,
onContextMenu: (event) => {
event.preventDefault(); event.stopPropagation()
const anchor = event.currentTarget
setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor })
},
}}
>
<TabTitle>{callbacks.renderTabTitle?.(tab) ?? tab.title}</TabTitle>
@@ -42,8 +42,8 @@ export interface PaneCallbacks {
/** Whether the embedder popup is preparing or visible; suppresses tab tooltips. */
readonly tabMenuOpen?: boolean | undefined
/** Override the kit popup; the embedder owns rendering and dismissal. */
readonly openTabMenu?: ((tab: import('../contract/types.ts').TabRecord, paneId: PaneId, point: { x: number; y: number }) => void) | undefined
/** Extra rows for the kit popup; ignored when openTabMenu is supplied. */
readonly getTabMenuProps?: ((tab: import('../contract/types.ts').TabRecord, paneId: PaneId, handlers?: Pick<import('react').HTMLAttributes<HTMLElement>, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) => Pick<import('react').HTMLAttributes<HTMLElement>, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) | undefined
/** Extra rows for the kit popup; ignored when getTabMenuProps is supplied. */
readonly renderTabMenuItems: TabMenuExtras | undefined
/** The pane whose strip hosts the embedder's surface-wide controls. */
readonly chromePaneId: PaneId
@@ -736,6 +736,8 @@ describe('DockSurface', () => {
// A press on the nested control must not reach the chip's gesture handler;
// if it did, the pointer would be captured and this click would never land.
fireEvent.pointerDown(close)
expect(fireEvent.keyDown(close, { key: 'Enter' })).toBe(true)
expect(fireEvent.keyDown(close, { key: ' ' })).toBe(true)
fireEvent.click(close)
expect(intents.closeTab).toHaveBeenCalledTimes(1)
expect(intents.focusTab).not.toHaveBeenCalled()
-3
View File
@@ -13,9 +13,6 @@
},
{
"path": "../ui-primitives"
},
{
"path": "../../util/context-menu"
}
]
}
@@ -18,8 +18,8 @@
en: 3d9faac620769c8f
zh: 2d3e55294f6dbb42
/deepseek-ai-dsh-client-ui-file-actions/use-this-package/original-content:
en: 86e97f802d58b0ea
zh: e4f8d80dbcee80c3
en: fce75f484abb2ebd
zh: bd91a6e5226f1f76
/deepseek-ai-dsh-client-ui-file-actions/understand-the-implementation:
en: 8f3817d3526a290a
zh: 827ea59f30dd7a4b
+1 -1
View File
@@ -37,7 +37,7 @@ Desktop completion toasts offer a Show in folder text action (Show in Finder on
Filename and MIME normalization reuse the file-drag utility, so saving and dragging retain the same original format and filename policy.
Response MIME takes precedence over a suggested filename or media type when naming downloaded originals. Image copying decodes the captured original into PNG for clipboard interoperability. `copyImageAt` addresses the current pixels at a coordinate, provides no completion result, and cannot bind the image captured by a card or keyboard menu. Copying therefore reads the captured source even when an image moves, changes, or is covered by the menu. Downloads and save-as never use that conversion. The plugin contributes file and image copy, download, save-as, and localized feedback through `ctx.menus`, independently of `ui-open-in-app`. Other service callers own their feedback. Rejected reads do not start writes. Desktop downloads use `webContents.downloadURL` and `DownloadItem`: direct originals remain URLs; authorized Session reads become temporary Blob URLs retained until completion. Response MIME names the original file, downloads reserve distinct paths, and save-as uses Chromium’s save dialog and replacement behavior. Only the completed event returns a destination; cancellation is silent and interruption reports failure. Navigation or window closure cancels pending downloads. Plugin disposal aborts browser reads and pending reveal requests and releases object URLs.
Response MIME takes precedence over a suggested filename or media type when naming downloaded originals. Image copying decodes the captured original into PNG for clipboard interoperability. `copyImageAt` addresses the current pixels at a coordinate, provides no completion result, and cannot bind the image captured by a card or keyboard menu. Copying therefore reads the captured source even when an image moves, changes, or is covered by the menu. Downloads and save-as never use that conversion. The plugin contributes file and image copy, download, save-as, and localized feedback through `ctx.contextMenus`, independently of `ui-open-in-app`. Other service callers own their feedback. Rejected reads do not start writes. Desktop downloads use `webContents.downloadURL` and `DownloadItem`: direct originals remain URLs; authorized Session reads become temporary Blob URLs retained until completion. Response MIME names the original file, downloads reserve distinct paths, and save-as uses Chromium’s save dialog and replacement behavior. Only the completed event returns a destination; cancellation is silent and interruption reports failure. Navigation or window closure cancels pending downloads. Plugin disposal aborts browser reads and pending reveal requests and releases object URLs.
Desktop downloads follow Chromium’s session and network policy. Only clipboard fallback reads use the bounded credential-free HTTP(S) reader, rejecting the application Host at every redirect. Other destinations use the machine’s network policy. Ordinary Web reads require the remote server to allow browser CORS; a denied response produces failure feedback without starting a save.
+1 -1
View File
@@ -37,7 +37,7 @@ Desktop 完成提示提供「在文件夹显示」文字操作(macOS 为「在
文件名与 MIME 规范化复用文件拖动工具,因此保存与拖动采用相同的原始格式和文件名规则。
下载原始内容时,响应 MIME 优先于建议文件名或媒体类型。图片复制将捕获的原始内容解码为 PNG,保证剪贴板互通。`copyImageAt` 定位坐标上的当前内容,没有完成回执,也不能绑定卡片或键盘菜单捕获的图片。因此,即使图片移动、替换或被菜单遮挡,复制仍读取捕获的来源。下载与另存为不使用该转换。本插件通过 `ctx.menus` 贡献文件和图片的复制、下载、另存为及本地化提示,不依赖 `ui-open-in-app`。其他服务调用方负责自己的提示。读取失败不会开始写入。Desktop 下载使用 `webContents.downloadURL` 与 `DownloadItem`:直接来源保留为 URL,经授权的 Session 读取转为临时 Blob URL 并保留到完成。响应 MIME 决定原始文件名,下载独占不同路径,另存为使用 Chromium 的保存对话框和替换行为。仅完成事件返回目标路径;取消不提示,下载中断显示失败。导航或关闭窗口会取消待完成的下载。插件释放时取消浏览器读取及待完成的定位请求,并释放对象 URL。
下载原始内容时,响应 MIME 优先于建议文件名或媒体类型。图片复制将捕获的原始内容解码为 PNG,保证剪贴板互通。`copyImageAt` 定位坐标上的当前内容,没有完成回执,也不能绑定卡片或键盘菜单捕获的图片。因此,即使图片移动、替换或被菜单遮挡,复制仍读取捕获的来源。下载与另存为不使用该转换。本插件通过 `ctx.contextMenus` 贡献文件和图片的复制、下载、另存为及本地化提示,不依赖 `ui-open-in-app`。其他服务调用方负责自己的提示。读取失败不会开始写入。Desktop 下载使用 `webContents.downloadURL` 与 `DownloadItem`:直接来源保留为 URL,经授权的 Session 读取转为临时 Blob URL 并保留到完成。响应 MIME 决定原始文件名,下载独占不同路径,另存为使用 Chromium 的保存对话框和替换行为。仅完成事件返回目标路径;取消不提示,下载中断显示失败。导航或关闭窗口会取消待完成的下载。插件释放时取消浏览器读取及待完成的定位请求,并释放对象 URL。
Desktop 下载遵循 Chromium 会话及网络策略。只有剪贴板回退读取使用有界、无凭据的 HTTP(S) 读取器,每次重定向均禁止访问应用 Host。其他目标遵循本机网络策略。普通 Web 读取要求远端服务允许浏览器 CORS;响应被拒绝时显示失败提示,不开始保存。
@@ -28,7 +28,7 @@ export function registerFileMenus(ctx: Context, actions: FileActions, t: (key: F
const result = await actions.save(source, mode)
lifetime.signal.throwIfAborted()
if (result.status === 'cancelled') return
ctx.menus.notify(
ctx.contextMenus.notify(
t(result.status === 'requested' ? 'menu.downloadStarted' : mode === 'download' ? 'menu.downloaded' : 'menu.saved'),
true,
result.status !== 'saved' ? undefined : {
@@ -44,9 +44,9 @@ export function registerFileMenus(ctx: Context, actions: FileActions, t: (key: F
}
const copy = async (source: FileActionSource): Promise<void> => {
await actions.copyImage(source)
ctx.menus.notify(t('menu.copied'), true)
ctx.contextMenus.notify(t('menu.copied'), true)
}
ctx.effect(() => ctx.menus.register({
ctx.effect(() => ctx.contextMenus.register({
id: 'file-actions.file', target: 'file', order: 200,
resolve: async ({ sessionId, path }, signal) => {
let absolutePath: string | undefined
@@ -82,7 +82,7 @@ export function registerFileMenus(ctx: Context, actions: FileActions, t: (key: F
]
},
}), 'file actions: file menu')
ctx.effect(() => ctx.menus.register({
ctx.effect(() => ctx.contextMenus.register({
id: 'file-actions.image', target: 'image', order: 100,
resolve: (target) => {
const { sessionId, path } = target
@@ -24,7 +24,7 @@ export function apply(ctx: Context): void {
const actions = new BrowserFileActions(desktop)
ctx.provide('fileActions', actions)
ctx.effect(() => () => { actions.dispose() }, 'file actions: lifetime')
ctx.inject(['menus', 'locale', 'remote', 'remote.workspaceFiles', 'remote.session'], (child) => {
ctx.inject(['contextMenus', 'locale', 'remote', 'remote.workspaceFiles', 'remote.session'], (child) => {
child.effect(() => child.locale.register('fileActions', { zh, en, ja, es, fr, de, ru, pt }), 'file actions: locale')
registerFileMenus(child, actions, child.locale.bind('fileActions'))
})
@@ -13,7 +13,7 @@ it('contributes file and image operations without open-in-app and withdraws them
const ctx = new Context()
const failure = vi.fn(), notify = vi.fn()
const registry = new MenuRegistry(failure)
ctx.provide('menus', { register: registry.register.bind(registry), notify } as never)
ctx.provide('contextMenus', { register: registry.register.bind(registry), notify } as never)
const registerLocale = vi.fn(() => () => {})
ctx.provide('locale', { register: registerLocale, bind: () => (key: string) => key } as never)
const readBytes = vi.fn(async () => ({ ok: true as const, value: { data: [1, 2, 3] } }))
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
/** Frame interactions with a real store and explicitly driven browser measurements. */
import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots'
import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
@@ -100,7 +101,7 @@ function mountFrame(windowWidth = frameWidth, content?: AppFrameProps['renderSlo
useFoldedLandscape={bindSnapshotSelector(foldedLandscape)}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useResource={useResource}
useContextMenu={contextMenuHook()} useResource={useResource}
useWorkspaces={sel => sel(workspaceState)}
{...allowAllAccess}
t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key}
@@ -43,7 +43,7 @@ export function registerContextMenus(
return undefined
}
}
ctx.effect(() => ctx.menus.register({
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.workspace', target: 'workspace', order: 200,
resolve: async ({ path }, signal) => {
await directories.load()
@@ -57,7 +57,7 @@ export function registerContextMenus(
} } }]
},
}), 'open-in-app: Workspace context menu')
ctx.effect(() => ctx.menus.register({
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.directory', target: 'directory', order: 100,
resolve: async ({ sessionId, path }, signal) => {
await paths.load()
@@ -69,12 +69,12 @@ export function registerContextMenus(
run: () => open(absolutePath, 'reveal') },
{ id: 'copyPath', icon: <IconCopyOutlineRegular />, label: t('menu.copyDirectoryPath'), failureLabel: t('menu.copyFailed'), run: async () => {
await navigator.clipboard.writeText(absolutePath)
ctx.menus.notify(t('menu.copied'), true)
ctx.contextMenus.notify(t('menu.copied'), true)
} },
]
},
}), 'open-in-app: directory context menu')
ctx.effect(() => ctx.menus.register({
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.file', target: 'file', order: 100,
resolve: async (target, signal) => {
const { sessionId, path, line, paneId } = target
@@ -102,12 +102,12 @@ export function registerContextMenus(
run: async () => {
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
await navigator.clipboard.writeText(absolutePath ?? resolveWorkspacePath(cwd, path))
ctx.menus.notify(t('menu.copied'), true)
ctx.contextMenus.notify(t('menu.copied'), true)
} },
]
},
}), 'open-in-app: file context menu')
ctx.effect(() => ctx.menus.register({
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.url', target: 'url', order: 100,
resolve: ({ sessionId, url }) => [
{ id: 'sidebar', icon: <IconPanelLeftOutlineRegular />, label: t('menu.sidebar'), disabled: ctx.sidebarRightTabs.get('browser') === undefined,
@@ -115,11 +115,11 @@ export function registerContextMenus(
{ id: 'external', label: t('menu.external'),
run: () => { window.open(url, '_blank', 'noopener,noreferrer') } },
{ id: 'copy', icon: <IconCopyOutlineRegular />, failureLabel: t('menu.copyFailed'), label: t('menu.copyLink'), run: async () => {
await navigator.clipboard.writeText(url); ctx.menus.notify(t('menu.copied'), true)
await navigator.clipboard.writeText(url); ctx.contextMenus.notify(t('menu.copied'), true)
} },
],
}), 'open-in-app: URL context menu')
ctx.effect(() => ctx.menus.register({
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.image', target: 'image', order: 200,
resolve: async (target, signal) => {
const { sessionId, path } = target
@@ -46,7 +46,7 @@ export type { OpenPathActionProps, OpenPathInjected } from './OpenPathAction.tsx
export type { OpenPathEmptyActionProps } from './OpenPathEmptyAction.tsx'
/** Required services: sessions, layout selection, the slot registry, copy, Remote calls, and shortcuts. */
export const inject = ['menus', 'sidebarRight', 'sidebarRightTabs', 'sessions', 'slots', 'locale', 'remote', 'remote.session', 'remote.workspaceFiles', 'shortcuts', 'layout']
export const inject = ['contextMenus', 'sidebarRight', 'sidebarRightTabs', 'sessions', 'slots', 'locale', 'remote', 'remote.session', 'remote.workspaceFiles', 'shortcuts', 'layout']
/**
* Client plugin body: register dictionaries, workspace directory controls, and
@@ -18,7 +18,7 @@ export function registerSessionReveal(
t: PropsLocale<'open-in-app'>['t'],
report: OpenInAppToastSource['report'],
): void {
ctx.effect(() => ctx.menus.register({
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.reveal', target: 'session', order: 500,
observe: () => [controller.apps, ctx.sessions.list, ctx.locale],
resolve: ({ sessionId }) => {
@@ -82,7 +82,7 @@ async function bench() {
const notify = vi.fn()
const menus = new MenuRegistry(notify)
ctx.effect(() => () => { menus.dispose() })
ctx.provide('menus', { register: menus.register.bind(menus), resolve: menus.resolve.bind(menus), open: vi.fn(), notify } as never)
ctx.provide('contextMenus', { register: menus.register.bind(menus), resolve: menus.resolve.bind(menus), open: vi.fn(), notify } as never)
ctx.provide('layout', layout)
ctx.provide('sessions', { list })
ctx.provide('shortcuts', { register: (command: ShortcutCommand) => { commands.set(command.id, command); return () => { commands.delete(command.id) } }, catalog: createSnapshotStore([]) })
@@ -125,7 +125,7 @@ describe('open-in-app browser half', () => {
list.set({ ...list.getSnapshot(), ids: [id], byId: {
[id]: { id, displayTitle: 'Captured', cwd: '/captured', running: false, blank: false, updatedAt: 0, retainedBy: {} },
} })
const menu = ctx.menus.resolve({ kind: 'session', value: { sessionId: id, displayTitle: 'Captured' } })
const menu = ctx.contextMenus.resolve({ kind: 'session', value: { sessionId: id, displayTitle: 'Captured' } })
const toast = toastSource(ctx)
try {
expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0)
@@ -142,7 +142,7 @@ describe('open-in-app browser half', () => {
body: JSON.stringify({ app: 'finder', path: '/captured' }),
}))
} finally { menu.dispose(); await fiber.dispose() }
const removed = ctx.menus.resolve({ kind: 'session', value: { sessionId: id, displayTitle: 'Captured' } })
const removed = ctx.contextMenus.resolve({ kind: 'session', value: { sessionId: id, displayTitle: 'Captured' } })
expect(removed.source.getSnapshot()).toEqual([])
removed.dispose()
})
@@ -254,7 +254,7 @@ describe('open-in-app browser half', () => {
})
it('declares the services it binds', () => {
expect(inject).toEqual(['menus', 'sidebarRight', 'sidebarRightTabs', 'sessions', 'slots', 'locale', 'remote', 'remote.session', 'remote.workspaceFiles', 'shortcuts', 'layout'])
expect(inject).toEqual(['contextMenus', 'sidebarRight', 'sidebarRightTabs', 'sessions', 'slots', 'locale', 'remote', 'remote.session', 'remote.workspaceFiles', 'shortcuts', 'layout'])
})
it('does not open a retained workspace while a global panel hides the Session header', async () => {
@@ -48,7 +48,7 @@ async function boot() {
const directories = new OpenInAppController(directoryFetch)
const paths = new OpenInAppPathController(remote)
ctx.provide('remote', remote as never)
ctx.provide('menus', { register: registry.register.bind(registry), notify } as never)
ctx.provide('contextMenus', { register: registry.register.bind(registry), notify } as never)
ctx.provide('sessions', { list: sessions } as never)
ctx.provide('fileActions', fileActions as never)
ctx.provide('sidebarRight', sidebar as never)
@@ -32,7 +32,7 @@ it('offers image actions on file targets, copies absolute paths, and keeps save
readBytes: async () => ({ ok: true as const, value: { data: [...new TextEncoder().encode('<svg/>')] } }),
} }
ctx.provide('remote', remote as never)
ctx.provide('menus', { register: registry.register.bind(registry), notify } as never)
ctx.provide('contextMenus', { register: registry.register.bind(registry), notify } as never)
ctx.provide('sessions', { list: createSnapshotStore({ byId: { [id]: { cwd: '/workspace' } } }) } as never)
ctx.provide('fileActions', { save, copyImage: copy, canSaveAs: true } as never)
const paths = new OpenInAppPathController(remote)
@@ -24,7 +24,7 @@ it.each([
locale.register(NS, { en, zh }); locale.setLocale('en')
const registry = new MenuRegistry(vi.fn())
onTestFinished(() => { registry.dispose() })
ctx.provide('menus', { register: registry.register.bind(registry) } as never)
ctx.provide('contextMenus', { register: registry.register.bind(registry) } as never)
const sessionId = SessionId('captured')
const list = createSnapshotStore<SessionListState>({ ids: [sessionId], byId: {
[sessionId]: { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} },
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -63,7 +64,7 @@ const runtime = {
useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useWorkspaces: (() => { throw new Error('unused') }) as never,
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
}
function mount(controller: PermissionPresetSettingsController) {
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import assert from 'node:assert/strict'
import { Context, Service } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
@@ -253,7 +254,7 @@ describe('ui-plugin-manager browser plugin', () => {
const unread = () => { throw new Error('The sidebar icon must not read application state') }
const glyph = render(<PluginsPanelIcon size={18} active={false}
usePanelInfo={unread} useSessions={unread} useSessionStatus={unread} useSessionRetainInfo={unread}
useWorkspaces={unread} useResource={unread} {...allowAllAccess} />)
useWorkspaces={unread} useContextMenu={contextMenuHook()} useResource={unread} {...allowAllAccess} />)
expect(glyph.container.querySelector('svg')?.getAttribute('width')).toBe('18')
expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 })
expect(icon.locale).toBe(NS)
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
import type { ConfigPageForm } from '../src/client/slot-contract.ts'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
@@ -158,7 +159,7 @@ function renderTab(
useSessionStatus: unusedStandardHook,
useSessionRetainInfo: unusedStandardHook,
useResource: unusedStandardHook,
...allowAllAccess,
...allowAllAccess, useContextMenu: contextMenuHook(),
}
const navigation = createNavigationStore().create()
const props: PluginManagerPageProps = {
@@ -45,8 +45,8 @@
en: 45f4cd1ef96bb6d2
zh: f264d25b780ad1dc
/deepseek-ai-dsh-client-ui-primitives/further-exploration:
en: decab96616908dbb
zh: e46af983275b304d
en: 40cbac0b21819d5c
zh: f076241625fdc059
/deepseek-ai-dsh-client-ui-primitives/model-experience:
en: 3120df665299c634
zh: 1d9f365d091d0d2a
+1 -1
View File
@@ -204,7 +204,7 @@ These pages place the atoms in the client stack and the design system.
`QuotePreview` shares the compact quote excerpt and clipped-only inline HoverCard between composers and sent messages. The popover sizes to its text up to 300px and stays within the viewport. Both surfaces display the exact text; editable drafts may supply a localized whole-quote removal action. Quotes opt into the nearest `HoverCardGroup` for exclusive preview display. The primitive performs no recognition or navigation.
`MarkdownDelegate.openContextMenu` receives explicit file, URL, or original-image targets and pointer coordinates. File paths and line numbers are carried by callbacks. File mentions can supply `openMenu`; `ImageLightbox.onContextMenu` delegates the original image gesture. An image menu owns Escape and Tab while it holds focus, returning to the preview before closing it.
`MarkdownDelegate.getContextMenuProps` receives explicit file, URL, or original-image targets and returns gesture props. File paths and line numbers are carried by callbacks. File mentions can supply `contextMenuProps`; `ImageLightbox.contextMenuProps` delegates the original image gesture. An image menu owns Escape and Tab while it holds focus, returning to the preview before closing it.
<a id="model-experience"></a>
## Model Experience
+1 -1
View File
@@ -204,7 +204,7 @@ kind: "package-library"
`QuotePreview` 在 composer 与已发送消息之间共享紧凑 quote 摘录和仅截断时显示的行内 HoverCard。原文弹层按文本内容确定宽度,最大为 300px,并限制在视口内。两处均展示原文;可编辑草稿可以提供本地化的整条 quote 删除操作。quote 加入最近的 `HoverCardGroup`,使原文弹层互斥显示。该 primitive 不负责识别或导航。
`MarkdownDelegate.openContextMenu` 接收明确的文件、链接或原图目标及指针坐标。文件路径与行号通过回调传递。文件引用可提供 `openMenu`;`ImageLightbox.onContextMenu` 转发原图手势。图片菜单持有焦点时负责 Escape 与 Tab,先返回预览再关闭预览。
`MarkdownDelegate.getContextMenuProps` 接收明确的文件、链接或原图目标并返回手势 props。文件路径与行号通过回调传递。文件引用可提供 `contextMenuProps`;`ImageLightbox.contextMenuProps` 转发原图手势。图片菜单持有焦点时负责 Escape 与 Tab,先返回预览再关闭预览。
<a id="model-experience"></a>
## 模型体验

Some files were not shown because too many files have changed in this diff Show More