diff --git a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml index 6d670d955f..458cbcc733 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md index 24fbe53d7e..da02632cf2 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md index a2919384d2..cb0b3c5f3c 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md @@ -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 或工具包会重复已有生命周期与注入机制。 ## 考虑过的替代方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 628ca51002..5d725a8ce8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4080475534..183f27f2bf 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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) | diff --git a/docs/module-graph.md b/docs/module-graph.md index e8f70a0a51..433befe3a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c344b974f3..3cc4f214b7 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -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) | diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 17ae54903e..a935485749 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -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 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index d64184dbc0..ee77bb1e27 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -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. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index db7509aefe..bed9962aeb 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -67,6 +67,8 @@ Slot 声明固定两个相互独立的维度。 +[右键菜单插件](../../packages/client/ui-context-menu/README.zh.md) 通过 `provideRoot` 贡献 `useContextMenu`;每个作用域接收同一个由插件拥有的触发器工厂。提供方卸载时,注入属性与保留的处理器一并失效。 + ## 组件输入 注册组件会在 binding 位置收到组装后的输入。组件应从这些类型推导 props,不要重新抄写成员。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml index cccf7cf2da..14793638ee 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml @@ -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 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md index 08eb204624..d7f70eeda4 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md @@ -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 ``. 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 ``. 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. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md index e6c8838ddb..3531924bd2 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md @@ -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` 改为图标元素,并用 `` 包装图标 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` 改为图标元素,并用 `` 包装图标 URL。自定义 Chat 消息流、节点和工具适配器在 `openFile` 之外转发 `getContextMenuProps`,ToolRow 使用同名 prop。Chat 在手势发生时解析 Session、路径基准和行号,再捕获对象。ReferenceChip 和 ImageLightbox 接收 `contextMenuProps`。删除 `contextMenuTrigger` 和 `menuPoint` 导入,其独立工具包已删除。不要向 ui-primitives 增加手势导出。 diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index ecf23dd6e9..6f29e41a8c 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -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, diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 31aa10ad12..ca7bbc4154 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -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 diff --git a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx index 578234c898..96061a63fe 100644 --- a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx @@ -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 = {}, 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() { diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 1ea42a6af1..5aab3c1dcd 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -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 = {}, 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 } diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 129fc54967..2f5d2d5d93 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -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", diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 5fcd02a735..b3425cda68 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -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 }} />} {open && src !== null && { - 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} /> ))} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index b50a79317a..2f056c2d27 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -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 + compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} getImageMenuProps={source => getTriggerProps({ kind: 'image', value: source })} /> } /** diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 3255631525..ef0f496a78 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -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({ diff --git a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx index 547a61bdee..98f3d923a3 100644 --- a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx +++ b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx @@ -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')) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d2713b9c8e..f488367886 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -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( '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 }) diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index db177c89fe..706dc4531b 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -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', }) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 075a30e9f8..853d6a70e2 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -46,9 +46,6 @@ }, { "path": "../ui-context-menu" - }, - { - "path": "../../util/context-menu" } ] } diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 9f192f6382..b8748707e4 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 15e37e17ab..ef56ead2ae 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -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. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 3ddde46f6f..93f4fab980 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -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。每个位置均可独立替换,无需复制画廊实现。 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index a677dbd54c..c8a468b48c 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -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:~" diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 47a2a9990b..6c6e497b92 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -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) => { diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 455f90f9a2..d39cd8ed30 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -8,7 +8,7 @@ type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace

(() => { 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], diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx index f6f3d46a12..07a096e9c2 100644 --- a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx @@ -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) { diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index f5875aed6f..4e32eedafa 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -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 diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index f0ad4b0cc4..ca944577a4 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -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>( + target => getTriggerProps(() => resolveContextMenuTarget(target)), [getTriggerProps, resolveContextMenuTarget]) const order = useChat(s => s.order) const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries) const entries = useMemo(() => groupedEntries @@ -231,14 +235,14 @@ export function ChatView({ )} {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 })}

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 :
{tail}
// Interruption-frozen partials carry no messageId, so they address no diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 08be32417e..354235c7e9 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -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 + /** 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 & { /** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */ export interface ChatFlowOwnerProps extends Pick { + 'cwd' | 'openFile' | 'getContextMenuProps' | 'inspectCall' | 'forkAt' | 'loadImage' | 'fileMentions'> { readonly entries: readonly RenderEntry[] readonly pendingInputs: readonly (PendingSubmission | InboxState['next-step'][number])[] readonly lastInputTurn: number | undefined diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 3154e8c952..effde9971e 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -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. diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index efea109389..8b09381034 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -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(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() }) diff --git a/packages/client/ui-chat/tests/context-menu.client.spec.tsx b/packages/client/ui-chat/tests/context-menu.client.spec.tsx index 251aee41cc..d5f7dce571 100644 --- a/packages/client/ui-chat/tests/context-menu.client.spec.tsx +++ b/packages/client/ui-chat/tests/context-menu.client.spec.tsx @@ -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( {}}> + const view = render( {}}> Plugin link - + ) expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true) expect(open).not.toHaveBeenCalled() diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index f67f18eb92..a3e9ec4fd8 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -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), diff --git a/packages/client/ui-context-menu/README.i18n.yaml b/packages/client/ui-context-menu/README.i18n.yaml index d9abc128a4..57a7370b4d 100644 --- a/packages/client/ui-context-menu/README.i18n.yaml +++ b/packages/client/ui-context-menu/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-context-menu/README.md b/packages/client/ui-context-menu/README.md index 4b45f4ff6e..72d6dc839b 100644 --- a/packages/client/ui-context-menu/README.md +++ b/packages/client/ui-context-menu/README.md @@ -25,23 +25,23 @@ Add actions to right-click menus without importing a React component. Targets re ## 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 `` 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. diff --git a/packages/client/ui-context-menu/README.zh.md b/packages/client/ui-context-menu/README.zh.md index 3bd6dda274..1d7d786ffc 100644 --- a/packages/client/ui-context-menu/README.zh.md +++ b/packages/client/ui-context-menu/README.zh.md @@ -25,23 +25,23 @@ kind: "package-reference" ## 使用本包 -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。贡献插件提供图标元素,或用 `` 包装应用图标;registry 不序列化或映射图标。结构化操作的图标按分隔线组对齐;组件贡献保留自己的布局。普通关闭保留弹窗 80ms,替换保留 10ms;打开至少等待 20ms,再用 40ms 淡入。减少动态效果偏好会禁用淡入淡出。 diff --git a/packages/client/ui-context-menu/package.json b/packages/client/ui-context-menu/package.json index 951f2f7a35..6aacc8717f 100644 --- a/packages/client/ui-context-menu/package.json +++ b/packages/client/ui-context-menu/package.json @@ -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:*", diff --git a/packages/client/ui-context-menu/src/client/index.ts b/packages/client/ui-context-menu/src/client/index.ts index 37e37fe7fa..96f7221200 100644 --- a/packages/client/ui-context-menu/src/client/index.ts +++ b/packages/client/ui-context-menu/src/client/index.ts @@ -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({ diff --git a/packages/client/ui-context-menu/src/client/registry.ts b/packages/client/ui-context-menu/src/client/registry.ts index 764802a034..c829375922 100644 --- a/packages/client/ui-context-menu/src/client/registry.ts +++ b/packages/client/ui-context-menu/src/client/registry.ts @@ -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[] resolve(target: MenuTarget, signal: AbortSignal): readonly MenuAction[] | Promise @@ -24,14 +24,14 @@ export class MenuRegistry { * @param input - provider definition. * @returns removal of this contribution and its pending resolutions. */ - register(input: MenuContribution): () => void { + register(input: MenuContribution): () => 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 () => { diff --git a/packages/client/ui-context-menu/src/client/trigger.ts b/packages/client/ui-context-menu/src/client/trigger.ts new file mode 100644 index 0000000000..6aa3ab4304 --- /dev/null +++ b/packages/client/ui-context-menu/src/client/trigger.ts @@ -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( + 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, 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) + }, + } + }, + } +} diff --git a/packages/client/ui-context-menu/src/client/types.ts b/packages/client/ui-context-menu/src/client/types.ts index bda49107c1..8fc7302d44 100644 --- a/packages/client/ui-context-menu/src/client/types.ts +++ b/packages/client/ui-context-menu/src/client/types.ts @@ -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, '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 { readonly run?: () => void | Promise } /** A namespaced contribution, ordered within its target family. */ -export interface MenuContribution { +export interface MenuContribution { readonly id: string readonly target: K readonly order: number @@ -57,13 +77,13 @@ export interface MenuContribution { * @param target - clicked object. * @returns sources owned by the contributing feature; unsubscribed on dismissal or unload. */ - observe?(target: MenuTargetMap[K]): readonly ObservableSnapshot[] + observe?(target: ContextMenuTargetMap[K]): readonly ObservableSnapshot[] /** * @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 + resolve(target: ContextMenuTargetMap[K], signal: AbortSignal): readonly MenuAction[] | Promise } /** 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(contribution: MenuContribution): () => void + register(contribution: MenuContribution): () => void /** * Open the single shared popup. * @param target - captured object. diff --git a/packages/client/ui-context-menu/tests/plugin.client.spec.ts b/packages/client/ui-context-menu/tests/plugin.client.spec.ts index 84cf576440..e0fd911e96 100644 --- a/packages/client/ui-context-menu/tests/plugin.client.spec.ts +++ b/packages/client/ui-context-menu/tests/plugin.client.spec.ts @@ -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['create']> - ctx.provide('slots', { + ctx.provide('slots', { provideRoot: () => () => {}, inject: (_name: string, register: Parameters[1]) => ctx.effect(register), register: (entry: { inject: () => MenuOverlayInjected; store: ReturnType }) => { 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['create']> - ctx.provide('slots', { + ctx.provide('slots', { provideRoot: () => () => {}, inject: (_name: string, register: Parameters[1]) => ctx.effect(register), register: (entry: { store: ReturnType }) => { 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[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() } }) diff --git a/packages/client/ui-context-menu/tests/registry.client.spec.ts b/packages/client/ui-context-menu/tests/registry.client.spec.ts index 041d7788f1..c533f3b49d 100644 --- a/packages/client/ui-context-menu/tests/registry.client.spec.ts +++ b/packages/client/ui-context-menu/tests/registry.client.spec.ts @@ -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' } } diff --git a/packages/client/ui-context-menu/tests/target-key.client.spec.ts b/packages/client/ui-context-menu/tests/target-key.client.spec.ts index d9a877c2d8..71d6b70efa 100644 --- a/packages/client/ui-context-menu/tests/target-key.client.spec.ts +++ b/packages/client/ui-context-menu/tests/target-key.client.spec.ts @@ -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', () => { diff --git a/packages/client/ui-context-menu/tests/trigger.client.spec.tsx b/packages/client/ui-context-menu/tests/trigger.client.spec.tsx new file mode 100644 index 0000000000..7c80412ed2 --- /dev/null +++ b/packages/client/ui-context-menu/tests/trigger.client.spec.tsx @@ -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 => )} + + ) + 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(
+ + + + {createPortal(, document.body)} +
) + 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 + }) + 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() + expect(fireEvent.contextMenu(detached.getByRole('button'))).toBe(true) + expect(fireEvent.keyDown(detached.getByRole('button'), { key: 'ContextMenu' })).toBe(true) + expect(source.getSnapshot()).toBeNull() +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index dee09d8e15..33d555d294 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index dedf0d42df..b205164e0d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 58a1acb682..579ff4e92a 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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` 支持。 ## 模型体验 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index dbb5fe3939..f168e7f6c8 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -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", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index d4a2bd3e35..3328e1e5da 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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): 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) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 28df60cfa2..369f562a7b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 2f1abdfe72..14602cbca0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -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 ( - { - event.preventDefault(); event.stopPropagation() - openSessionMenu(summary.id, summary.displayTitle, menuPoint(event)) - }}> + {index > 0 && /} {lineage ? summary.subagent diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index f4b80c891c..33d461982c 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -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), diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index a657abf806..b9c34d916d 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -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' diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 6a18cede9a..f751424886 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -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), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 380097b5d1..539f341cd1 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -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), diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 4583814093..7378f68d3f 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -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 createSnapshotStore(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'], diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index e5cb0cc805..5f07662274 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -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 ( - ).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( 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, diff --git a/packages/client/ui-conversation/tsconfig.client.json b/packages/client/ui-conversation/tsconfig.client.json index 08cec77b8b..0187cf5d72 100644 --- a/packages/client/ui-conversation/tsconfig.client.json +++ b/packages/client/ui-conversation/tsconfig.client.json @@ -107,9 +107,6 @@ }, { "path": "../ui-context-menu" - }, - { - "path": "../../util/context-menu" } ], "exclude": [ diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 937476d595..4172b1f4c2 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -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", diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index ca4abab6c5..89cfa24afa 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -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> showCodeDiff: ObservableSnapshot @@ -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 & { matched: DeliverablesMatch -} & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { +} & PropsLocale & Pick & Pick & InjectFace & 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, diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 0e8e284cb7..e18e0353fd 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -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) { const reveal = host?.fileManager ?? 'directory' @@ -39,7 +38,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onMenu, a return
) + 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() 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') diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 89b356668d..0b009d5915 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -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: , - 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: , + t: makeTranslate(en), + } +} it('localizes reveal failures and restores the description when progress clears', () => { const p = props() const view = render() diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json index 70aa8447eb..bd78605122 100644 --- a/packages/client/ui-deliverables/tsconfig.client.json +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -71,9 +71,6 @@ }, { "path": "../ui-context-menu" - }, - { - "path": "../../util/context-menu" } ] } diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index 79f39dbcfd..c626d2c657 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -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", diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index c4d80a3764..aede5a5385 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -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, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) => Pick, '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, } diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index 2f3dba1252..ba3314ac17 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -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) => { + 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 ( {(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 }) + }, }} > {callbacks.renderTabTitle?.(tab) ?? tab.title} diff --git a/packages/client/ui-dockkit/src/components/render.ts b/packages/client/ui-dockkit/src/components/render.ts index 524d9c6cd9..c69cc0be30 100644 --- a/packages/client/ui-dockkit/src/components/render.ts +++ b/packages/client/ui-dockkit/src/components/render.ts @@ -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, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) => Pick, '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 diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 59f50bfab8..2954c76afb 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -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() diff --git a/packages/client/ui-dockkit/tsconfig.json b/packages/client/ui-dockkit/tsconfig.json index fe2e2124bf..82162fc41c 100644 --- a/packages/client/ui-dockkit/tsconfig.json +++ b/packages/client/ui-dockkit/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../ui-primitives" - }, - { - "path": "../../util/context-menu" } ] } diff --git a/packages/client/ui-file-actions/README.i18n.yaml b/packages/client/ui-file-actions/README.i18n.yaml index ba61ab0f28..d5dc2b87f5 100644 --- a/packages/client/ui-file-actions/README.i18n.yaml +++ b/packages/client/ui-file-actions/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-file-actions/README.md b/packages/client/ui-file-actions/README.md index 62e4898ac3..8aeba41c51 100644 --- a/packages/client/ui-file-actions/README.md +++ b/packages/client/ui-file-actions/README.md @@ -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. diff --git a/packages/client/ui-file-actions/README.zh.md b/packages/client/ui-file-actions/README.zh.md index 715bd326bc..8f072cf4ee 100644 --- a/packages/client/ui-file-actions/README.zh.md +++ b/packages/client/ui-file-actions/README.zh.md @@ -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;响应被拒绝时显示失败提示,不开始保存。 diff --git a/packages/client/ui-file-actions/src/client/context-menus.tsx b/packages/client/ui-file-actions/src/client/context-menus.tsx index fe81289fb4..b2beef41bb 100644 --- a/packages/client/ui-file-actions/src/client/context-menus.tsx +++ b/packages/client/ui-file-actions/src/client/context-menus.tsx @@ -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 => { 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 diff --git a/packages/client/ui-file-actions/src/client/index.ts b/packages/client/ui-file-actions/src/client/index.ts index cc9687c10e..111c24d99c 100644 --- a/packages/client/ui-file-actions/src/client/index.ts +++ b/packages/client/ui-file-actions/src/client/index.ts @@ -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')) }) diff --git a/packages/client/ui-file-actions/tests/plugin-menus.client.spec.ts b/packages/client/ui-file-actions/tests/plugin-menus.client.spec.ts index 8432e2ff4a..dfdb58dfe7 100644 --- a/packages/client/ui-file-actions/tests/plugin-menus.client.spec.ts +++ b/packages/client/ui-file-actions/tests/plugin-menus.client.spec.ts @@ -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] } })) diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index c35e914149..8aff85d326 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -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} diff --git a/packages/client/ui-open-in-app/src/client/context-menus.tsx b/packages/client/ui-open-in-app/src/client/context-menus.tsx index ab7e0a46e4..835d0bded8 100644 --- a/packages/client/ui-open-in-app/src/client/context-menus.tsx +++ b/packages/client/ui-open-in-app/src/client/context-menus.tsx @@ -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: , 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: , 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: , 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 diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index 2b604f32bc..3b43fdb520 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -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 diff --git a/packages/client/ui-open-in-app/src/client/session-reveal.tsx b/packages/client/ui-open-in-app/src/client/session-reveal.tsx index c89297f6d9..750a71cc52 100644 --- a/packages/client/ui-open-in-app/src/client/session-reveal.tsx +++ b/packages/client/ui-open-in-app/src/client/session-reveal.tsx @@ -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 }) => { diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index e7f4e4a015..70810aebc2 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -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 () => { diff --git a/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts b/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts index ca38e1964f..8eabfcc539 100644 --- a/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts @@ -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) diff --git a/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts b/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts index b1b677ea0e..65d33bd9c6 100644 --- a/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts @@ -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('')] } }), } } 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) diff --git a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts b/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts index 75fde7a436..1638722be5 100644 --- a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts @@ -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({ ids: [sessionId], byId: { [sessionId]: { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} }, diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 160bd49d80..75751a8589 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -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) { diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 2a6f019ad4..9069ce355b 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -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() + 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) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 1cb3a73e15..f56df2266f 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -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 = { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 5f11e4ae87..a7c622a1c0 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 53e38a53bc..04c8cb1913 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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. ## Model Experience diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3915771ea1..5f4a0f0053 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -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,先返回预览再关闭预览。 ## 模型体验 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index db4bb8bf8d..40b94573d8 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -50,8 +50,7 @@ "micromark-extension-gfm": "^3.0.0", "micromark-util-sanitize-uri": "^2.0.1", "simple-icons": "16.31.0", - "@deepseek-ai/dsh-util-file-drag": "workspace:*", - "@deepseek-ai/dsh-util-context-menu": "workspace:*" + "@deepseek-ai/dsh-util-file-drag": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-primitives/src/ImageLightbox.tsx b/packages/client/ui-primitives/src/ImageLightbox.tsx index 523f315578..8be4fe1d53 100644 --- a/packages/client/ui-primitives/src/ImageLightbox.tsx +++ b/packages/client/ui-primitives/src/ImageLightbox.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef } from 'react' -import type { MouseEventHandler } from 'react' import { createPortal } from 'react-dom' import { IconCloseOutlineRegular } from './icons/index.tsx' import css from './ImageLightbox.module.css' @@ -22,16 +21,16 @@ export interface ImageLightboxLabels { * @param props.src - the original image URL. * @param props.alt - the image's alt text. * @param props.labels - dialog and close-control strings. - * @param props.onContextMenu - optional original-image context gesture. + * @param props.contextMenuProps - optional original-image gesture bindings. * @param props.onClose - dismiss callback owned by the opener. * @returns the modal preview dialog. */ -export function ImageLightbox({ src, alt, labels, onClose, onContextMenu }: { +export function ImageLightbox({ src, alt, labels, onClose, contextMenuProps }: { src: string alt: string labels: ImageLightboxLabels onClose: () => void - onContextMenu?: MouseEventHandler | undefined + contextMenuProps?: Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'> | undefined }) { const closeRef = useRef(null) const restoreRef = useRef(null) @@ -61,7 +60,7 @@ export function ImageLightbox({ src, alt, labels, onClose, onContextMenu }: { >