From 411f157983a825cdbb00457bcff78c30df5a5469 Mon Sep 17 00:00:00 2001 From: zhc Date: Thu, 22 Oct 2026 13:38:49 +0800 Subject: [PATCH 01/23] feat(context-menu): add extensible resource actions and shared popup lifecycle --- ...10-21-context-menu-contributions.i18n.yaml | 19 ++ .../2026-10-21-context-menu-contributions.md | 27 +++ ...026-10-21-context-menu-contributions.zh.md | 27 +++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/package.json | 3 +- apps/desktop/src/file-actions.ts | 138 ++++++++++++ apps/desktop/src/ipc.ts | 4 + apps/desktop/src/main.ts | 2 + apps/desktop/src/preload-app.ts | 7 + apps/desktop/tests/file-actions.spec.ts | 88 ++++++++ apps/desktop/tests/preload-app.spec.ts | 15 ++ apps/desktop/tsconfig.client.json | 3 + apps/desktop/tsconfig.host.json | 3 + apps/web/tests/context-menus.e2e.ts | 191 ++++++++++++++++ .../tests/expected/context-menus/image.txt | 4 + .../tests/expected/context-menus/session.txt | 6 + apps/web/tsconfig.json | 1 + docs/config-catalog.md | 2 + docs/config-catalog.zh.md | 2 + .../context-menu-services/guide.i18n.yaml | 16 ++ .../context-menu-services/guide.md | 19 ++ .../context-menu-services/guide.zh.md | 19 ++ packages/bundle/web-app/cordis.patch.yml | 4 + packages/bundle/web-app/package.json | 4 +- .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 + packages/client/ui-attachment/README.zh.md | 2 + packages/client/ui-attachment/package.json | 6 +- .../client/ui-attachment/src/MessageImage.tsx | 20 +- .../src/client/MessageImages.tsx | 4 +- .../client/ui-attachment/src/client/index.ts | 5 +- .../image-slot-isolation.client.spec.tsx | 1 + .../tests/message-image.client.spec.tsx | 2 + .../ui-attachment/tests/plugin.client.spec.ts | 3 +- packages/client/ui-attachment/tsconfig.json | 3 + packages/client/ui-chat/README.i18n.yaml | 8 +- packages/client/ui-chat/README.md | 4 + packages/client/ui-chat/README.zh.md | 4 + packages/client/ui-chat/package.json | 6 +- packages/client/ui-chat/src/client/apply.ts | 16 ++ .../ui-chat/src/client/chat/ChatView.tsx | 8 +- .../ui-chat/src/client/chat/context-menu.ts | 38 ++++ .../ui-chat/src/client/contract/slots.ts | 2 + packages/client/ui-chat/src/client/index.ts | 1 + .../tests/apply-inject.client.spec.tsx | 2 + .../ui-chat/tests/chat-apply.client.spec.tsx | 2 + .../ui-chat/tests/chat-view.client.spec.tsx | 1 + .../tests/context-menu.client.spec.tsx | 41 ++++ .../tests/quota-notice.client.spec.tsx | 2 + packages/client/ui-chat/tsconfig.client.json | 3 + .../client/ui-context-menu/README.i18n.yaml | 40 ++++ packages/client/ui-context-menu/README.md | 90 ++++++++ packages/client/ui-context-menu/README.zh.md | 90 ++++++++ .../client/ui-context-menu/locale/en.json | 5 + .../client/ui-context-menu/locale/ja.json | 5 + .../client/ui-context-menu/locale/zh.json | 5 + packages/client/ui-context-menu/package.json | 66 ++++++ .../ui-context-menu/src/client/View.tsx | 48 +++++ .../ui-context-menu/src/client/index.ts | 119 ++++++++++ .../ui-context-menu/src/client/registry.ts | 120 +++++++++++ .../ui-context-menu/src/client/store.ts | 41 ++++ .../ui-context-menu/src/client/types.ts | 102 +++++++++ packages/client/ui-context-menu/src/index.ts | 4 + .../tests/overlay.client.spec.tsx | 72 +++++++ .../tests/plugin.client.spec.ts | 160 ++++++++++++++ .../tests/registry.client.spec.ts | 129 +++++++++++ packages/client/ui-context-menu/tsconfig.json | 42 ++++ .../client/ui-context-menu/tsdown.config.ts | 3 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + packages/client/ui-conversation/package.json | 6 +- .../ui-conversation/src/client/apply.ts | 3 + .../src/client/contract/slots.ts | 5 +- .../ui-conversation/src/client/index.ts | 1 + .../client/skeleton/ConversationSession.tsx | 7 +- .../tests/apply-inject.client.spec.tsx | 2 + .../tests/apply-wiring.client.spec.tsx | 2 + .../tests/assembly-surfaces.client.spec.tsx | 5 + .../tests/skeleton.client.spec.tsx | 2 +- .../ui-conversation/tsconfig.client.json | 3 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 + packages/client/ui-deliverables/README.zh.md | 2 + packages/client/ui-deliverables/package.json | 6 +- .../src/client/Deliverables.tsx | 4 +- .../src/client/PresentedFileCard.tsx | 6 +- .../ui-deliverables/src/client/index.ts | 9 +- .../src/client/turn-deliverables.ts | 5 +- .../tests/deliverables.client.spec.tsx | 2 + .../tests/presented-file-card.client.spec.tsx | 1 + .../ui-deliverables/tsconfig.client.json | 3 + .../ui-dockkit/src/components/DockSurface.tsx | 6 +- .../ui-dockkit/src/components/TabPanel.tsx | 5 + .../ui-dockkit/src/components/render.ts | 1 + .../client/ui-file-actions/README.i18n.yaml | 40 ++++ packages/client/ui-file-actions/README.md | 88 ++++++++ packages/client/ui-file-actions/README.zh.md | 88 ++++++++ .../client/ui-file-actions/locale/en.json | 5 + .../client/ui-file-actions/locale/ja.json | 5 + .../client/ui-file-actions/locale/zh.json | 5 + packages/client/ui-file-actions/package.json | 59 +++++ .../ui-file-actions/src/client/files.ts | 117 ++++++++++ .../ui-file-actions/src/client/index.ts | 18 ++ packages/client/ui-file-actions/src/index.ts | 4 + packages/client/ui-file-actions/src/types.ts | 69 ++++++ .../tests/files.client.spec.ts | 203 ++++++++++++++++++ packages/client/ui-file-actions/tsconfig.json | 21 ++ .../client/ui-file-actions/tsdown.config.ts | 3 + .../client/ui-open-in-app/README.i18n.yaml | 4 +- packages/client/ui-open-in-app/README.md | 4 + packages/client/ui-open-in-app/README.zh.md | 4 + packages/client/ui-open-in-app/package.json | 15 +- .../src/client/context-menus.ts | 151 +++++++++++++ .../client/ui-open-in-app/src/client/index.ts | 6 +- .../ui-open-in-app/src/client/locales.ts | 48 +++++ .../tests/browser-plugin.client.spec.ts | 7 +- .../tests/context-menus.client.spec.ts | 65 ++++++ packages/client/ui-open-in-app/tsconfig.json | 18 ++ .../client/ui-primitives/README.i18n.yaml | 8 +- packages/client/ui-primitives/README.md | 4 + packages/client/ui-primitives/README.zh.md | 4 + .../ui-primitives/src/ImageLightbox.tsx | 9 +- .../client/ui-primitives/src/Menu.module.css | 11 + packages/client/ui-primitives/src/Menu.tsx | 82 +++++-- .../ui-primitives/src/MenuSurface.module.css | 15 ++ .../client/ui-primitives/src/MenuSurface.tsx | 8 +- packages/client/ui-primitives/src/index.ts | 2 + .../src/markdown/MarkdownDelegate.tsx | 12 +- .../ui-primitives/src/markdown/render.tsx | 35 ++- .../client/ui-primitives/src/user-text.tsx | 8 +- .../tests/context-menu.client.spec.tsx | 34 +++ .../tests/markdown-file-links.client.spec.tsx | 16 ++ packages/client/ui-reference/README.i18n.yaml | 4 +- packages/client/ui-reference/README.md | 2 + packages/client/ui-reference/README.zh.md | 2 + .../ui-reference/src/client/FileReference.tsx | 4 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 2 + .../ui-sidebar-documentpreview/README.zh.md | 2 + .../ui-sidebar-documentpreview/package.json | 6 +- .../src/client/TextPreview.tsx | 17 +- .../src/client/image/ImageBody.tsx | 11 +- .../src/client/image/index.ts | 3 +- .../src/client/index.ts | 7 +- .../tests/apply.client.spec.ts | 1 + .../tests/document-seat.client.spec.tsx | 1 + .../tsconfig.client.json | 3 + .../client/ui-sidebar-files/README.i18n.yaml | 4 +- packages/client/ui-sidebar-files/README.md | 2 + packages/client/ui-sidebar-files/README.zh.md | 2 + packages/client/ui-sidebar-files/package.json | 6 +- .../ui-sidebar-files/src/client/FilesBody.tsx | 13 +- .../ui-sidebar-files/src/client/index.ts | 7 +- .../tests/apply.client.spec.ts | 1 + .../client/ui-sidebar-files/tsconfig.json | 3 + .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 2 + packages/client/ui-sidebar-right/README.zh.md | 2 + packages/client/ui-sidebar-right/package.json | 6 +- .../ui-sidebar-right/src/client/index.ts | 30 ++- .../ui-sidebar-right/src/client/locales.ts | 3 + .../ui-sidebar-right/src/client/service.ts | 17 ++ .../src/client/shell/SidebarRight.tsx | 14 +- .../src/client/shell/TabMenuContent.tsx | 19 ++ .../tests/apply.client.spec.ts | 7 +- .../tests/seat.client.spec.tsx | 7 +- .../tests/service.client.spec.ts | 23 ++ .../client/ui-sidebar-right/tsconfig.json | 3 + packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 + packages/client/ui-tool/README.zh.md | 2 + packages/client/ui-tool/package.json | 3 +- .../src/client/tool/components/ToolRow.tsx | 1 + .../tests/assembly-surfaces.client.spec.tsx | 2 + .../tests/chat-ptc-subcalls.client.spec.tsx | 2 + .../tests/toolview-slot.client.spec.tsx | 3 + .../ui-trajectory/tests/views.client.spec.tsx | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + packages/client/ui-workspace/package.json | 6 +- .../ui-workspace/src/client/contract/slots.ts | 4 +- .../client/ui-workspace/src/client/index.ts | 24 ++- .../ui-workspace/src/client/rows/Rows.tsx | 58 +++-- .../src/client/rows/WorkspaceBrowser.tsx | 14 +- .../client/session-actions/SessionMenu.tsx | 11 + .../ui-workspace/tests/apply.client.spec.ts | 7 +- .../tests/host-home-staleness.client.spec.tsx | 2 + .../tests/rename-assembly.client.spec.tsx | 9 +- .../ui-workspace/tests/rows.client.spec.tsx | 122 ++++------- .../tests/workspace-browser.client.spec.tsx | 8 +- packages/client/ui-workspace/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 126 +++++++---- .../references/packages.md | 2 + pnpm-lock.yaml | 114 ++++++++++ scripts/gen-cordis-catalog.ts | 2 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 5 + tsconfig.client.json | 2 + tsconfig.host.json | 1 + 203 files changed, 3824 insertions(+), 309 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md create mode 100644 .agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md create mode 100644 apps/desktop/src/file-actions.ts create mode 100644 apps/desktop/tests/file-actions.spec.ts create mode 100644 apps/web/tests/context-menus.e2e.ts create mode 100644 apps/web/tests/expected/context-menus/image.txt create mode 100644 apps/web/tests/expected/context-menus/session.txt create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md create mode 100644 packages/client/ui-chat/src/client/chat/context-menu.ts create mode 100644 packages/client/ui-chat/tests/context-menu.client.spec.tsx create mode 100644 packages/client/ui-context-menu/README.i18n.yaml create mode 100644 packages/client/ui-context-menu/README.md create mode 100644 packages/client/ui-context-menu/README.zh.md create mode 100644 packages/client/ui-context-menu/locale/en.json create mode 100644 packages/client/ui-context-menu/locale/ja.json create mode 100644 packages/client/ui-context-menu/locale/zh.json create mode 100644 packages/client/ui-context-menu/package.json create mode 100644 packages/client/ui-context-menu/src/client/View.tsx create mode 100644 packages/client/ui-context-menu/src/client/index.ts create mode 100644 packages/client/ui-context-menu/src/client/registry.ts create mode 100644 packages/client/ui-context-menu/src/client/store.ts create mode 100644 packages/client/ui-context-menu/src/client/types.ts create mode 100644 packages/client/ui-context-menu/src/index.ts create mode 100644 packages/client/ui-context-menu/tests/overlay.client.spec.tsx create mode 100644 packages/client/ui-context-menu/tests/plugin.client.spec.ts create mode 100644 packages/client/ui-context-menu/tests/registry.client.spec.ts create mode 100644 packages/client/ui-context-menu/tsconfig.json create mode 100644 packages/client/ui-context-menu/tsdown.config.ts create mode 100644 packages/client/ui-file-actions/README.i18n.yaml create mode 100644 packages/client/ui-file-actions/README.md create mode 100644 packages/client/ui-file-actions/README.zh.md create mode 100644 packages/client/ui-file-actions/locale/en.json create mode 100644 packages/client/ui-file-actions/locale/ja.json create mode 100644 packages/client/ui-file-actions/locale/zh.json create mode 100644 packages/client/ui-file-actions/package.json create mode 100644 packages/client/ui-file-actions/src/client/files.ts create mode 100644 packages/client/ui-file-actions/src/client/index.ts create mode 100644 packages/client/ui-file-actions/src/index.ts create mode 100644 packages/client/ui-file-actions/src/types.ts create mode 100644 packages/client/ui-file-actions/tests/files.client.spec.ts create mode 100644 packages/client/ui-file-actions/tsconfig.json create mode 100644 packages/client/ui-file-actions/tsdown.config.ts create mode 100644 packages/client/ui-open-in-app/src/client/context-menus.ts create mode 100644 packages/client/ui-open-in-app/tests/context-menus.client.spec.ts create mode 100644 packages/client/ui-primitives/tests/context-menu.client.spec.tsx create mode 100644 packages/client/ui-sidebar-right/src/client/shell/TabMenuContent.tsx create mode 100644 packages/client/ui-workspace/src/client/session-actions/SessionMenu.tsx 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 new file mode 100644 index 0000000000..6d670d955f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-21-context-menu-contributions.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md +/agent-note-context-menu-contributions: + en: 380bed76b1989558 + zh: ecbb5c15933431f6 +/agent-note-context-menu-contributions/problem: + en: baf7f6e331fd9f03 + zh: 8a45faaad34e6b0c +/agent-note-context-menu-contributions/decision: + en: 3a865968cfea5a86 + zh: 8159892c54d16f2d +/agent-note-context-menu-contributions/alternatives-considered: + en: 1fc419cf3a0642d3 + zh: f774e64679bed2ab +/agent-note-context-menu-contributions/consequences: + en: 7a55e405714d598f + zh: d7ff74ace672bf22 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 new file mode 100644 index 0000000000..24fbe53d7e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md @@ -0,0 +1,27 @@ +# Agent Note: Context menu contributions + +Status: implemented + +English | [中文](2026-10-21-context-menu-contributions.zh.md) + +## Problem + +Session and tab menus already accept React Slot entries. Reusing only those entries would require external plugins to supply React components and would leave action discovery, target capture, and cancellation coupled to each trigger. + +## Decision + +[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. + +## Alternatives considered + +**React Slots alone.** They preserve arbitrary visual content but cannot supply a presentation-independent action model. They remain available for existing component contributions. + +**A central switch over built-in target kinds.** It would make each new plugin-owned target require changes to the menu implementation. Declaration merging and registered providers instead keep target-specific behavior with its owner. + +**Native menus first.** It would duplicate the existing keyboard, placement, and dismissal implementation while excluding component-only legacy entries. The shared Web renderer serves Desktop too; another renderer can consume the plain action view independently. + +## Consequences + +An opening captures its target and contribution set. Dismissal cancels metadata work; unloading withdraws that provider's actions. Selected actions own execution and must recheck mutable resources. Legacy React entries are absent from plain-data resolution, so native rendering cannot assume complete parity. These lifetimes are covered by registry tests; browser scenarios cover unchanged selection, original bytes, and nested-overlay dismissal. 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 new file mode 100644 index 0000000000..a2919384d2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 右键菜单贡献 + +Status: implemented + +[English](2026-10-21-context-menu-contributions.md) | 中文 + +## 问题 + +Session 和标签页菜单已经支持 React Slot 条目。仅复用这些条目会要求外部插件提供 React 组件,并使动作发现、目标捕获和取消逻辑继续耦合到各个触发位置。 + +## 决策 + +[右键菜单](../../../../packages/client/ui-context-menu/README.zh.md)负责类型化目标类别、贡献解析和回调生命周期。功能插件通过与外部 Client 插件相同的服务注册动作。纯数据展示字段让渲染器无需加载贡献方的 React 组件即可消费已解析动作。现有 Session 和标签页 Slot 保留在各功能所属的适配器中,从而保留这些扩展位置。 + +[文件动作](../../../../packages/client/ui-file-actions/README.zh.md)独立于菜单和图片组件负责原始内容传输。保存保留原始编码;剪贴板互操作可能需要转换为 PNG。浏览器下载不提供本机路径,因此在文件夹显示要求已验证的 Host 文件,或原生保存返回的不透明引用。 + +## 考虑过的替代方案 + +**仅使用 React Slot。** 它们支持任意视觉内容,但无法提供独立于展示方式的动作模型。现有组件贡献仍可使用它们。 + +**在中心位置按内置目标类型分支。** 这种方式会使每个新增的插件目标都要求修改菜单实现。声明合并与注册式提供方让目标特有行为保留在其所属插件。 + +**优先实现原生菜单。** 这会重复已有的键盘、定位和关闭实现,同时无法容纳仅提供组件的旧条目。Desktop 同样使用共享 Web 渲染器;其他渲染器可以独立消费纯数据动作视图。 + +## 影响 + +打开菜单时捕获目标和贡献集合。关闭取消元数据任务;插件卸载撤回该提供方的动作。已选动作自行负责执行,并须重新检查可变资源。旧 React 条目不参与纯数据解析,因此原生渲染不能假定功能完全一致。注册表测试覆盖这些生命周期;浏览器场景覆盖选择不变、原始字节以及嵌套浮层关闭。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index f554ad3863..648ae79158 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -66,8 +66,8 @@ en: 182a9c6705213e13 zh: 681762f83490f4c6 /deepseek-harness-desktop/updates/local-updater-qualification: - en: 68ec2f8bd5763ea6 - zh: 23b932dfe8503602 + en: 08fb3bd513b5c75e + zh: 9d75fc9f1a312a58 /deepseek-harness-desktop/low-level-development-overrides: en: 8264b135871e8978 zh: 0939aafb1b2961fc diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 931401ec77..e0361a1b97 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -356,6 +356,8 @@ The command builds the Desktop shell and runs its coordinator with real Electron The downloaded bytes are inert, and the installation call is recorded rather than executed. The test substitutes browser opening and clipboard writing to avoid external navigation and clipboard changes. It does not boot the full product workspace, qualify a real installer or restart, verify publisher signatures, or exercise differential updates or macOS. Stalled policy requests, feed requests, and payload transfers exercise real deadlines and recovery. The actual ordinary dialog verifies isolated preload loading, card geometry, an unfiltered parent page, cancellation, task-warning choices, and explicit installation approval; account-row component tests provide separate evidence. The [local qualification decision](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md) and [verification record](tests/README.md) preserve these limits; production release requirements remain unchanged. +Native original-file operations accept only the trusted application frame. Saved paths stay in the main process and references expire on window reload or close. Remote image reads omit credentials, stop on navigation, and use `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS` (30000 ms) and `DSH_DESKTOP_IMAGE_READ_MAX_BYTES` (67108864 bytes). [File actions](../../packages/client/ui-file-actions/README.md) defines saving and clipboard behavior. + ## Low-level development overrides diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 262eee3ac7..3ca5d5d8ee 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -356,6 +356,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 下载内容是不可执行的测试字节,安装调用仅记录而不执行。测试替换浏览器打开与剪贴板写入,避免外部导航和剪贴板修改。它不启动完整产品工作区,不验证真实安装器或重启,不验证发布者签名,也不覆盖差分更新或 macOS。停滞的策略请求、清单请求和负载传输会执行真实截止时间及恢复。真实常规弹窗验证隔离预加载、卡片尺寸、未施加模糊的父页面、取消、任务警告选项与显式安装批准;账户行组件测试另行提供证据。[本地验证决策](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md)和[验证记录](tests/README.zh.md)保留这些限制;生产发布要求保持不变。 +原生原始文件操作仅接受可信应用页面。保存路径保留在主进程,窗口刷新或关闭后引用失效。远程图片读取不携带凭据,导航时终止,并使用 `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS`(30000 毫秒)与 `DSH_DESKTOP_IMAGE_READ_MAX_BYTES`(67108864 字节)。[文件操作](../../packages/client/ui-file-actions/README.zh.md) 定义保存和剪贴板行为。 + ## 底层开发覆盖项 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 18a188eb76..7d6aa6680c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -84,6 +84,7 @@ "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", - "@deepseek-ai/dsh-util-file-drag": "workspace:*" + "@deepseek-ai/dsh-util-file-drag": "workspace:*", + "@deepseek-ai/dsh-client-ui-file-actions": "workspace:*" } } diff --git a/apps/desktop/src/file-actions.ts b/apps/desktop/src/file-actions.ts new file mode 100644 index 0000000000..e9f2238bcf --- /dev/null +++ b/apps/desktop/src/file-actions.ts @@ -0,0 +1,138 @@ +/** Trusted-window original-file saves and clipboard transfers. Saved paths stay in the main process. */ +import { app, clipboard, ClipboardItem, dialog, ipcMain, nativeImage, shell } from 'electron' +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import { open, rename, rm, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, extname, join } from 'node:path' +import { randomUUID } from 'node:crypto' +import type { FileSaveResult, SavedFileId } from '@deepseek-ai/dsh-client-ui-file-actions/types' +import { DESKTOP_IPC } from './ipc.ts' +import { resolveDurationMs } from './duration-env.ts' + +/** @param request - untrusted IPC input. @returns checked original bytes and destination policy. */ +function saveRequest(request: unknown): { bytes: Uint8Array; name: string; mode: 'download' | 'saveAs' } { + if (typeof request !== 'object' || request === null || !('bytes' in request) || !(request.bytes instanceof Uint8Array) + || !('name' in request) || typeof request.name !== 'string' || request.name.length === 0 + || basename(request.name) !== request.name || /[<>:"/\\|?*\x00-\x1f]/u.test(request.name) + || request.name === '.' || request.name === '..' + || Buffer.byteLength(request.name) > 255 || /[. ]$/u.test(request.name) + || /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(request.name) + || !('mode' in request) || (request.mode !== 'download' && request.mode !== 'saveAs')) throw new Error('Invalid file save request') + return { bytes: request.bytes, name: request.name, mode: request.mode } +} + +/** + * Save without overwriting a download; explicit save-as replaces only after the complete write. + * @param bytes - unmodified original content. + * @param destination - main-process destination from Downloads or the native picker. + * @param replace - picker-confirmed replacement. + * @returns committed destination. + */ +export async function saveOriginal(bytes: Uint8Array, destination: string, replace: boolean): Promise { + if (replace) { + const temporary = join(dirname(destination), `.dsh-save-${randomUUID()}`) + try { await writeFile(temporary, bytes, { flag: 'wx' }); await rename(temporary, destination) } + finally { await rm(temporary, { force: true }) } + return destination + } + const extension = extname(destination) + const stem = destination.slice(0, destination.length - extension.length) + for (let index = 0; ; index++) { + const path = index === 0 ? destination : `${stem} (${index})${extension}` + let file + try { file = await open(path, 'wx') } + catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') continue + throw error + } + try { await file.writeFile(bytes) } + catch (error) { await file.close(); await rm(path, { force: true }); throw error } + await file.close() + return path + } +} + +/** @param assertOwner - rejects shell pages, subframes, and unowned windows. */ +export function installFileActions(assertOwner: (event: IpcMainInvokeEvent) => BrowserWindow): void { + const saved = new Map() + const owners = new WeakSet() + const timeoutMs = resolveDurationMs(process.env, 'DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS', 30_000) + const maxBytes = Number(process.env.DSH_DESKTOP_IMAGE_READ_MAX_BYTES ?? 64 * 1024 * 1024) + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('DSH_DESKTOP_IMAGE_READ_MAX_BYTES must be a positive integer') + const retainOwner = (owner: BrowserWindow): void => { + if (owners.has(owner)) return + owners.add(owner) + const clear = (): void => { + for (const [id, entry] of saved) if (entry.owner === owner) saved.delete(id) + } + owner.webContents.on('did-start-loading', clear) + owner.once('closed', () => { clear(); owner.webContents.removeListener('did-start-loading', clear) }) + } + ipcMain.handle(DESKTOP_IPC.fileSave, async (event, value: unknown): Promise => { + const owner = assertOwner(event) + const request = saveRequest(value) + let destination = join(app.getPath('downloads'), request.name) + if (request.mode === 'saveAs') { + const result = await dialog.showSaveDialog(owner, { defaultPath: destination, properties: ['showOverwriteConfirmation', 'createDirectory'] }) + if (result.canceled || !result.filePath || owner.isDestroyed()) return { status: 'cancelled' } + destination = result.filePath + } + assertOwner(event) + const path = await saveOriginal(request.bytes, destination, request.mode === 'saveAs') + if (owner.isDestroyed()) return { status: 'saved' } + assertOwner(event) + retainOwner(owner) + const fileId = randomUUID() as SavedFileId + saved.set(fileId, { owner, path }) + return { status: 'saved', fileId } + }) + ipcMain.handle(DESKTOP_IPC.fileReveal, async (event, id: unknown) => { + const owner = assertOwner(event) + const entry = typeof id === 'string' ? saved.get(id as SavedFileId) : undefined + if (entry === undefined || entry.owner !== owner) throw new Error('Saved file unavailable') + if (!(await stat(entry.path)).isFile()) throw new Error('Saved file unavailable') + assertOwner(event) + shell.showItemInFolder(entry.path) + }) + ipcMain.handle(DESKTOP_IPC.imageCopy, async (event, bytes: unknown) => { + assertOwner(event) + if (!(bytes instanceof Uint8Array)) throw new Error('Invalid clipboard image') + const image = nativeImage.createFromBuffer(Buffer.from(bytes)) + if (image.isEmpty()) throw new Error('Image cannot be decoded') + await clipboard.write([new ClipboardItem({ 'image/png': new Blob([new Uint8Array(image.toPNG())]) })]) + }) + ipcMain.handle(DESKTOP_IPC.imageRead, async (event, value: unknown) => { + const owner = assertOwner(event) + if (typeof value !== 'string') throw new Error('Invalid image URL') + const url = new URL(value) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('Invalid image URL') + const lifetime = new AbortController() + const abort = (): void => { lifetime.abort() } + owner.once('closed', abort) + owner.webContents.once('did-start-loading', abort) + try { + const response = await fetch(url, { + signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(timeoutMs)]), credentials: 'omit', referrerPolicy: 'no-referrer', + }) + if (!response.ok) throw new Error('Original image unavailable') + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('Original image unavailable') + const chunks: Uint8Array[] = [] + let size = 0 + try { + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + size += chunk.value.byteLength + if (size > maxBytes) throw new Error('Original image exceeds the download limit') + chunks.push(chunk.value) + } + } finally { await reader.cancel(); reader.releaseLock() } + assertOwner(event) + lifetime.signal.throwIfAborted() + return { bytes: new Uint8Array(Buffer.concat(chunks)), mediaType: response.headers.get('content-type') ?? '' } + } finally { + owner.removeListener('closed', abort) + owner.webContents.removeListener('did-start-loading', abort) + } + }) +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index e62a84f0a1..c343572e85 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,3 +1,4 @@ +import type { DesktopFileActions } from '@deepseek-ai/dsh-client-ui-file-actions/types' /** Typed preload operations exposed only by the Electron shell. */ import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol' @@ -7,6 +8,8 @@ import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { + fileSave: 'dsh-desktop:file-save', fileReveal: 'dsh-desktop:file-reveal', + imageCopy: 'dsh-desktop:image-copy', imageRead: 'dsh-desktop:image-read', shortcutsInput: 'dsh-desktop:shortcuts-input', shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window', shortcutsGet: 'dsh-desktop:shortcuts-get', @@ -76,6 +79,7 @@ export interface DesktopUpdatePresentation { /** Product documents cannot supply update versions, package URLs, or installation authorization. */ export interface DshDesktopProductApi { + readonly fileActions: DesktopFileActions readonly protocolVersion: 1 readonly browser: DesktopBrowserBridge readonly fileDrag: DesktopFileDrag diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fb938dec84..b8a5fd9b87 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,4 @@ +import { installFileActions } from './file-actions.ts' import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ @@ -761,6 +762,7 @@ async function main(): Promise { || !event.senderFrame.url.startsWith('dsh-app://app/')) throw new Error('Rejected Platform command') return owner } + installFileActions(assertMainApplication) ipcMain.on(PLATFORM_IPC.bootstrap, (event) => { try { event.returnValue = platformView.bootstrap(event) } catch { event.returnValue = null } diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index c9937a301e..fd79878235 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,3 +1,4 @@ +import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types' /** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' @@ -14,6 +15,12 @@ import type { FileDragHandle } from '@deepseek-ai/dsh-util-file-drag' function createProductApi(): DshDesktopProductApi { return { protocolVersion: 1, + fileActions: { + save: request => ipcRenderer.invoke(DESKTOP_IPC.fileSave, request) as Promise, + reveal: id => ipcRenderer.invoke(DESKTOP_IPC.fileReveal, id) as Promise, + copyImage: bytes => ipcRenderer.invoke(DESKTOP_IPC.imageCopy, bytes) as Promise, + readImage: url => ipcRenderer.invoke(DESKTOP_IPC.imageRead, url) as Promise<{ bytes: Uint8Array; mediaType: string }>, + }, browser: createDesktopBrowserBridge(), fileDrag: { prepare: (source, requestId) => ipcRenderer.invoke(DESKTOP_IPC.fileDragPrepare, source, requestId) as diff --git a/apps/desktop/tests/file-actions.spec.ts b/apps/desktop/tests/file-actions.spec.ts new file mode 100644 index 0000000000..88bde9ff84 --- /dev/null +++ b/apps/desktop/tests/file-actions.spec.ts @@ -0,0 +1,88 @@ +/** Native original-byte saves and window-scoped IPC ownership. */ +import { EventEmitter } from 'node:events' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +import { installFileActions, saveOriginal } from '../src/file-actions.ts' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const electron = vi.hoisted(() => ({ + app: { getPath: vi.fn() }, ipcMain: { handle: vi.fn() }, dialog: { showSaveDialog: vi.fn() }, + shell: { showItemInFolder: vi.fn() }, nativeImage: { createFromBuffer: vi.fn() }, clipboard: { write: vi.fn() }, + ClipboardItem: class { constructor(readonly content: object) {} }, +})) +vi.mock('electron', () => electron) +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); vi.clearAllMocks(); vi.unstubAllGlobals() +}) +async function directory() { const path = await mkdtemp(join(tmpdir(), 'dsh-file-actions-')); roots.push(path); return path } + +it('preserves originals, avoids download collisions, and commits explicit replacements', async () => { + const root = await directory(), path = join(root, 'original.gif'), bytes = new Uint8Array([0, 128, 255]) + await writeFile(path, 'previous') + const copy = await saveOriginal(bytes, path, false) + expect(copy).toBe(join(root, 'original (1).gif')) + expect(await readFile(path, 'utf8')).toBe('previous') + expect(await readFile(copy)).toEqual(Buffer.from(bytes)) + await saveOriginal(bytes, path, true) + expect(await readFile(path)).toEqual(Buffer.from(bytes)) + expect((await readdir(root)).sort()).toEqual(['original (1).gif', 'original.gif']) +}) + +function harness(root: string) { + const owner = Object.assign(new EventEmitter(), { webContents: new EventEmitter(), isDestroyed: () => false }) + const assertOwner = vi.fn(() => owner as BrowserWindow) + electron.app.getPath.mockReturnValue(root) + installFileActions(assertOwner) + const event = {} as IpcMainInvokeEvent + const invoke = async (channel: string, value: unknown): Promise => { + const handler = electron.ipcMain.handle.mock.calls.find(([name]) => name === channel)?.[1] as + (event: IpcMainInvokeEvent, value: unknown) => Promise + return handler(event, value) + } + return { owner, assertOwner, invoke } +} + +it('reveals only its saved references and retires them on navigation', async () => { + const f = harness(await directory()) + const result = await f.invoke(DESKTOP_IPC.fileSave, { bytes: new Uint8Array([1]), name: 'image.gif', mode: 'download' }) as { fileId: string } + await f.invoke(DESKTOP_IPC.fileReveal, result.fileId) + expect(electron.shell.showItemInFolder).toHaveBeenCalledTimes(1) + f.owner.webContents.emit('did-start-loading') + await expect(f.invoke(DESKTOP_IPC.fileReveal, result.fileId)).rejects.toThrow('unavailable') + f.owner.emit('closed') + expect(f.owner.webContents.listenerCount('did-start-loading')).toBe(0) +}) + +it.each(['../bad', 'CON.gif', 'name.', 'x'.repeat(256)])('rejects unsafe IPC filename %s', async (name) => { + const root = await directory(), f = harness(root) + await expect(f.invoke(DESKTOP_IPC.fileSave, { bytes: new Uint8Array(), name, mode: 'download' })).rejects.toThrow('Invalid') + expect(await readdir(root)).toEqual([]) +}) + +it('treats picker cancellation as normal and rejects an unowned caller before saving', async () => { + const root = await directory(), f = harness(root) + electron.dialog.showSaveDialog.mockResolvedValue({ canceled: true }) + expect(await f.invoke(DESKTOP_IPC.fileSave, { bytes: new Uint8Array(), name: 'a.gif', mode: 'saveAs' })).toEqual({ status: 'cancelled' }) + f.assertOwner.mockImplementation(() => { throw new Error('Rejected owner') }) + await expect(f.invoke(DESKTOP_IPC.fileSave, {})).rejects.toThrow('Rejected owner') + expect(await readdir(root)).toEqual([]) +}) + +it('aborts original-image reads on window navigation and releases lifecycle listeners', async () => { + const f = harness(await directory()) + let signal!: AbortSignal + vi.stubGlobal('fetch', vi.fn((_url: URL, options: RequestInit) => new Promise((_resolve, reject) => { + signal = options.signal! + signal.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')) }, { once: true }) + }))) + const reading = f.invoke(DESKTOP_IPC.imageRead, 'https://images.example/original.gif') + f.owner.webContents.emit('did-start-loading') + await expect(reading).rejects.toThrow('Aborted') + expect(signal.aborted).toBe(true) + expect(f.owner.listenerCount('closed')).toBe(0) + expect(f.owner.webContents.listenerCount('did-start-loading')).toBe(0) +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index af9a9f226a..c81fe20f9d 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -38,6 +38,21 @@ it('exposes onboarding size activation to the application document', async () => expect(electron.ipcRenderer.send.mock.calls).toEqual([[DESKTOP_IPC.onboardingActive, true], [DESKTOP_IPC.onboardingActive, false]]) }) +it('routes original-file and clipboard requests through the trusted application bridge', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + const request = { bytes: new Uint8Array([1, 2]), name: 'image.gif', mode: 'download' as const } + await api.fileActions.save(request) + await api.fileActions.readImage('https://images.example/original.gif') + await api.fileActions.copyImage(request.bytes) + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ + [DESKTOP_IPC.fileSave, request], + [DESKTOP_IPC.imageRead, 'https://images.example/original.gif'], + [DESKTOP_IPC.imageCopy, request.bytes], + ]) +}) + it('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) await import('../src/preload-app.ts') diff --git a/apps/desktop/tsconfig.client.json b/apps/desktop/tsconfig.client.json index 3de33f06dd..eb17ebe087 100644 --- a/apps/desktop/tsconfig.client.json +++ b/apps/desktop/tsconfig.client.json @@ -18,6 +18,9 @@ }, { "path": "../../packages/client/product-analytics/tsconfig.client.json" + }, + { + "path": "../../packages/client/ui-file-actions" } ] } diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 21cda5fcf8..cd72569917 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -37,6 +37,9 @@ }, { "path": "../../packages/util/file-drag" + }, + { + "path": "../../packages/client/ui-file-actions" } ], "exclude": [ diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts new file mode 100644 index 0000000000..87e77856aa --- /dev/null +++ b/apps/web/tests/context-menus.e2e.ts @@ -0,0 +1,191 @@ +/** Context gestures against built plugins, seeded Sessions, and original files. */ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { chromium, type Browser, type Locator, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const GIF = Buffer.from('R0lGODdhMAAgAIEAAEtu5gAAAAAAAAAAACwAAAAAMAAgAEAIQQABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmQEBADs=', 'base64') +async function menuLabels(menu: Locator): Promise { + return menu.getByRole('menuitem').evaluateAll(items => items.map((item) => { + const copy = item.cloneNode(true) as HTMLElement + for (const decoration of copy.querySelectorAll('[aria-hidden="true"]')) decoration.remove() + return copy.textContent ?? '' + })) +} +function fixture(title: string): string { + const session = Session.create(SessionId('context-menus-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Show the output.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + session.append('session/title', { title, messageSeqs: [user.seq], source: { kind: 'fallback' } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { stream: [], turn: 1, step: 1, message: createAssistantMessage({ + content: [{ type: 'text', text: '[First file](first.txt#L2) [Second file](second.txt) [Website](https://example.com/path?q=1#fragment)\n\n![Original](original.gif) ![Remote](https://images.example/original.gif)\n\nCONTEXT_MENU_READY' }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0 }), + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: 1_000 + event.seq * 1_000 })), ''].join('\n') +} + +describe('web context menus', () => { + let scaffold: WebScaffold, browser: Browser, page: Page + let tripwire: ReturnType + beforeAll(async () => { + scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]) }) + await writeFile(join(scaffold.workspaceCwd, 'first.txt'), 'FIRST\nSECOND\nTHIRD\n') + await writeFile(join(scaffold.workspaceCwd, 'second.txt'), 'SECOND FILE\n') + await writeFile(join(scaffold.workspaceCwd, 'original.gif'), GIF) + await seedSession(scaffold, fixture('Menu foreground'), 'context-menu-first') + await seedSession(scaffold, fixture('Menu background'), 'context-menu-other') + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.setViewportSize({ width: 1291, height: 768 }) + tripwire = watchConsole(page) + await page.route('https://images.example/original.gif', route => route.fulfill({ body: GIF, contentType: 'image/gif' })) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto(scaffold.authenticatedUrl) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[data-row-key="session:context-menu-first"]').click() + await page.getByText('CONTEXT_MENU_READY', { exact: true }).waitFor() + }) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + it('shares Session entries without selecting the background Session, and dismisses at viewport edges', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-session')) + const background = page.locator('[data-row-key="session:context-menu-other"]') + await background.click({ button: 'right' }) + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor() + const names = await menuLabels(menu) + await expect(names.join('\n') + '\n').toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/session.txt')) + expect(names.some(name => name.includes('Rename'))).toBe(true) + expect(await background.getAttribute('aria-selected')).toBe('false') + await page.keyboard.press('Escape') + await background.getByRole('button', { name: /actions/i }).click() + await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor() + expect(await menuLabels(menu)).toEqual(names) + await background.getByRole('button', { name: /actions/i }).click() + expect(await menu.count()).toBe(0) + expect(await background.getAttribute('aria-selected')).toBe('false') + }) + + it('prepares complete menus, anchors above the frame inset, and treats repeated gestures as separate occurrences', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-gestures')) + const file = page.getByRole('button', { name: 'First file', exact: true }) + await file.focus() + await page.keyboard.press('Shift+F10') + await page.getByRole('menuitem', { name: 'Copy file path', exact: true }).waitFor() + await page.keyboard.press('End') + await page.keyboard.press('ArrowUp') + await page.keyboard.press('Enter') + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(join(scaffold.workspaceCwd, 'first.txt')) + expect(await file.evaluate(element => element === document.activeElement)).toBe(true) + await page.evaluate(() => { + document.documentElement.style.setProperty('--dsh-frame-overlay-top', '100px') + const frames: number[] = [] + const observer = new MutationObserver(() => { + for (const menu of document.querySelectorAll('[role="menu"]')) frames.push(menu.querySelectorAll('[role="menuitem"]').length) + }) + observer.observe(document.body, { subtree: true, childList: true }) + Object.assign(window, { menuObservation: { frames, observer } }) + }) + const header = page.locator('[data-session-title]').first() + const target = await header.count() > 0 ? header : page.getByText('Menu foreground', { exact: true }).last() + const box = (await target.boundingBox())! + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 } + await page.mouse.click(point.x, point.y, { button: 'right' }) + await page.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor() + expect((await page.getByRole('menu').boundingBox())!.y).toBeLessThan(100) + await page.mouse.click(point.x + 1, point.y, { button: 'right' }) + expect(await page.getByRole('menu').count()).toBe(0) + await file.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy file path', exact: true }).waitFor() + const previous = await page.getByRole('menu').elementHandle() + await page.getByRole('link', { name: 'Website', exact: true }).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy link address', exact: true }).waitFor() + expect(await previous!.evaluate(element => element.isConnected)).toBe(false) + await page.keyboard.press('Escape') + const counts = await page.evaluate(() => { + const observed = (window as Window & { menuObservation?: { frames: number[]; observer: MutationObserver } }).menuObservation + if (observed === undefined) throw new Error('Menu observer not installed') + observed.observer.disconnect() + document.documentElement.style.removeProperty('--dsh-frame-overlay-top') + return observed.frames + }) + expect(counts.length).toBeGreaterThan(0) + expect(counts.every(count => count > 0)).toBe(true) + }) + + it('preserves file line navigation, URL text, image format, and existing tab close actions', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-content')) + const file = page.getByRole('button', { name: 'First file', exact: true }) + await file.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).click() + await page.locator('[data-document-preview]').getByText('SECOND', { exact: true }).waitFor() + await page.getByRole('link', { name: 'Website', exact: true }).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy link address', exact: true }).click() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('https://example.com/path?q=1#fragment') + await page.getByRole('img', { name: 'Original', exact: true }).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Download', exact: true }).waitFor() + await expect((await menuLabels(page.getByRole('menu'))).join('\n') + '\n') + .toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/image.txt')) + expect(await page.getByRole('menuitem', { name: 'Show in folder', exact: true }).count()).toBe(1) + const downloadPromise = page.waitForEvent('download') + await page.getByRole('menuitem', { name: 'Download', exact: true }).click() + const download = await downloadPromise + expect(download.suggestedFilename().endsWith('.gif')).toBe(true) + await page.getByRole('alert').filter({ hasText: 'Download started' }).waitFor() + const path = await download.path() + expect(path).not.toBeNull() + if (path === null) throw new Error('Browser download unavailable') + expect(await readFile(path)).toEqual(GIF) + await page.getByRole('img', { name: 'Original', exact: true }).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy image', exact: true }).click() + await page.getByText('Copied', { exact: true }).waitFor() + expect(await page.evaluate(async () => (await navigator.clipboard.read()).some(item => item.types.includes('image/png')))).toBe(true) + await page.getByRole('img', { name: 'Remote', exact: true }).click({ button: 'right' }) + expect(await page.getByRole('menuitem', { name: 'Show in folder', exact: true }).isDisabled()).toBe(true) + await page.keyboard.press('Escape') + await page.getByRole('button', { name: 'Second file', exact: true }).click() + await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).waitFor() + const firstTab = page.locator('[role="tab"]').filter({ hasText: 'first.txt' }).first() + await firstTab.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Close', exact: true }).click() + expect(await firstTab.count()).toBe(0) + expect(await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + }) + it('keeps original preview open when dismissing its menu and fits both themes at the viewport edge', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-preview')) + await page.getByRole('button', { name: 'View full image: Original', exact: true }).click() + const preview = page.getByRole('dialog') + await preview.waitFor() + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await preview.getByRole('img').evaluate(element => element.dispatchEvent(new MouseEvent('contextmenu', { clientX: 1270, clientY: 735, bubbles: true }))) + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: 'Copy image', exact: true }).waitFor() + const box = await menu.boundingBox() + expect(box).not.toBeNull() + expect(box!.x).toBeGreaterThan(1000) + expect(box!.y).toBeGreaterThan(500) + expect(box!.x + box!.width).toBeLessThanOrEqual(1291) + expect(box!.y + box!.height).toBeLessThanOrEqual(768) + await saveFailureShot(page, `context-menus-${colorScheme}`) + await page.keyboard.press('Escape') + expect(await menu.count()).toBe(0) + expect(await preview.count()).toBe(1) + } + await page.keyboard.press('Escape') + expect(await preview.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }) + +}) diff --git a/apps/web/tests/expected/context-menus/image.txt b/apps/web/tests/expected/context-menus/image.txt new file mode 100644 index 0000000000..8ff2d8ba88 --- /dev/null +++ b/apps/web/tests/expected/context-menus/image.txt @@ -0,0 +1,4 @@ +Copy image +Download +Save as +Show in folder diff --git a/apps/web/tests/expected/context-menus/session.txt b/apps/web/tests/expected/context-menus/session.txt new file mode 100644 index 0000000000..c1e27fef8d --- /dev/null +++ b/apps/web/tests/expected/context-menus/session.txt @@ -0,0 +1,6 @@ +Pin session +Rename +Fork session +Archive session +Copy Session ID +Show in folder diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 057a50fbea..3987b48b21 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,6 +24,7 @@ // cannot see both sides of the cordis Context merges). "exclude": [ "tests/japanese-plugin-metadata.e2e.ts", + "tests/context-menus.e2e.ts", "tests/rich-text-quotes.e2e.ts", "tests/hover-input.e2e.ts", "tests/collapse-timing.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ef3541d8c8..19f2bc9a98 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4704,6 +4704,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | | `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) | | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-context-menu` | — | [`packages/client/ui-context-menu/src/index.ts`](../packages/client/ui-context-menu/src/index.ts) | | `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) | @@ -4711,6 +4712,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) | | `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-file-actions` | — | [`packages/client/ui-file-actions/src/index.ts`](../packages/client/ui-file-actions/src/index.ts) | | `@deepseek-ai/dsh-client-ui-file-drag` | — | [`packages/client/ui-file-drag/src/index.ts`](../packages/client/ui-file-drag/src/index.ts) | | `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) | | `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d0913be9bb..ef7f210886 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4706,6 +4706,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | | `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) | | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-context-menu` | — | [`packages/client/ui-context-menu/src/index.ts`](../packages/client/ui-context-menu/src/index.ts) | | `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) | @@ -4713,6 +4714,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) | | `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-file-actions` | — | [`packages/client/ui-file-actions/src/index.ts`](../packages/client/ui-file-actions/src/index.ts) | | `@deepseek-ai/dsh-client-ui-file-drag` | — | [`packages/client/ui-file-drag/src/index.ts`](../packages/client/ui-file-drag/src/index.ts) | | `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) | | `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) | 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 new file mode 100644 index 0000000000..76cda4fa4e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md +/: + en: b0a44570cee3ac45 + zh: f68438befed14c6e +/context-menu-services-in-custom-client-compositions: + en: b2018bf0250be579 + zh: 1d8a243d09f4128f +/context-menu-services-in-custom-client-compositions/change: + en: deab94202290347c + zh: a8addf40b42fd471 +/context-menu-services-in-custom-client-compositions/migration: + en: 74b5a5654e5f083c + zh: 9a3defd7b3941e4c 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 new file mode 100644 index 0000000000..7c29ce9c44 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Custom Client compositions must provide the context-menu and file-action services required by GUI plugins." +--- + +# Context menu services in custom Client compositions + +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. + +## 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, 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. +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. 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 new file mode 100644 index 0000000000..c614d64cd8 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "自定义 Client 组合必须提供 GUI 插件所需的右键菜单与文件动作服务。" +--- + +# 自定义 Client 组合中的右键菜单服务 + +[English](guide.md) | 中文 + +## 变更 + +GUI 插件现在注入 `menus` 以打开共享对象菜单。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、侧栏和工作区文件提供方并列。默认 `@deepseek-ai/dsh-web-app` bundle 已包含这些行。 +3. 按变更后的公开注入类型更新自定义 UI 适配器,包括 `WorkspaceBrowserInjected.openSessionMenu` 及其 `menuTarget` hook、`ChatViewInjected.openContextMenu`,以及文档和文件行的菜单回调。在插件中绑定 `menus` 服务,并将声明的回调传给组件。外部动作注册参见[菜单参考](../../../../packages/client/ui-context-menu/README.zh.md)。 +4. 重新构建并启动自定义 profile(配置档)。确认 GUI 插件正常激活、右键后台 Session 不改变选择,且现有 Session/标签页 Slot 贡献仍然显示。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 6e842cf61a..d8089a10a3 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -77,6 +77,10 @@ iconTimeoutMs: 10000 launchWatchMs: 1000 + - id: ui-file-actions + name: '@deepseek-ai/dsh-client-ui-file-actions' + - id: ui-context-menu + name: '@deepseek-ai/dsh-client-ui-context-menu' - id: ui-open-in-app name: '@deepseek-ai/dsh-client-ui-open-in-app' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 8281a74594..d203cf2249 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -192,7 +192,9 @@ "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*", "@deepseek-ai/dsh-harness-dev-context": "workspace:*", "@deepseek-ai/dsh-deliverable-file-references": "workspace:*", - "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*" + "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*", + "@deepseek-ai/dsh-client-ui-file-actions": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 65b551434d..3078e2f344 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -30,8 +30,8 @@ en: 07204b7d538b7434 zh: 705cc6b0c9387cce /deepseek-ai-dsh-client-ui-attachment/further-exploration: - en: 733ac3e4df52d6e9 - zh: 98a6a8cac7d66645 + en: 661e8285abb71b5d + zh: 0c421325537cdcda /deepseek-ai-dsh-client-ui-attachment/model-experience: en: 25566605d7b38667 zh: 927d3ea272893296 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 29e7f3aba0..0f92145a1d 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -82,6 +82,8 @@ Read these pages when the attachment surface is not enough. They move from the s ----- +Message images and original previews forward context gestures through the shared gallery to [ui-context-menu](../ui-context-menu/README.md). Original authorized URLs and MIME metadata remain independent from thumbnail presentation. + ## Model Experience diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 80db6ae928..6dfb52e2f2 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -82,6 +82,8 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪 ----- +消息图片和原图预览通过共享图片库将右键手势转发给 [ui-context-menu](../ui-context-menu/README.zh.md)。原始授权 URL 与 MIME 元数据独立于缩略图展示。 + ## 模型体验 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 13b2018090..07304c62de 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -35,7 +35,8 @@ "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-trajectory", "@deepseek-ai/dsh-client-ui-tool", - "@deepseek-ai/dsh-client-ui-file-drag" + "@deepseek-ai/dsh-client-ui-file-drag", + "@deepseek-ai/dsh-client-ui-context-menu" ], "platform": "web" } @@ -60,7 +61,8 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:*", "clsx": "^2.0.0", "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*", - "@deepseek-ai/dsh-util-file-drag": "workspace:*" + "@deepseek-ai/dsh-util-file-drag": "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 4cce54baa5..dd0edff75b 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,3 +1,4 @@ +import type { MenuTargetMap, MenuPoint } 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' @@ -67,12 +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 }: { +export function MessageImage({ image, load, variant, labels, fileDrag, openImageMenu }: { image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' | 'thumbnail' fileDrag: FileDragActions labels: MessageImageLabels + openImageMenu?: ((source: MenuTargetMap['image'], point: MenuPoint) => void) | undefined }) { const preview = 'preview' in image ? image.preview : undefined const attachment = 'attachment' in image ? image.attachment : undefined @@ -135,6 +137,11 @@ export function MessageImage({ image, load, variant, labels, fileDrag }: { 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 }) }, + { x: event.clientX, y: event.clientY }) + }} onClick={() => { if (src !== null) setOpen(true) }} > {src === null @@ -146,14 +153,19 @@ export function MessageImage({ image, load, variant, labels, fileDrag }: { : } - {open && src !== null && } + {open && src !== null && { + event.preventDefault(); event.stopPropagation() + openImageMenu({ url: src, name: label, ...(attachment === undefined ? {} : { mediaType: attachment.mediaType }) }, + { x: event.clientX, y: event.clientY }) + }} />} ) } /** 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 }: { +export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels, fileDrag, openImageMenu }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' @@ -161,6 +173,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail = thumbnail?: boolean fileDrag: FileDragActions labels: MessageImageLabels + openImageMenu?: ((source: MenuTargetMap['image'], point: MenuPoint) => void) | undefined }) { if (images.length === 0) return null const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single' @@ -174,6 +187,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail = variant={variant} fileDrag={fileDrag} labels={labels} + openImageMenu={openImageMenu} /> ))} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 0a95ddc66d..b50a79317a 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -12,11 +12,11 @@ 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 }: +export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag, openImageMenu }: FactoryComponentPropsOf<'conversation.images.content'>) { const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t]) return + compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} openImageMenu={openImageMenu} /> } /** diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 7ef44e1dd8..ba40ee3110 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -1,3 +1,4 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Browser attachment plugin: composer entries and independently replaceable image galleries. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' @@ -10,7 +11,7 @@ import { ComposerAttachments, type ComposerAttachmentsInjected } from './Compose import { MessageImages, MessageImagesContent } from './MessageImages.tsx' /** Slot registry required by the attachment presentation plugin. */ -export const inject = ['slots', 'fileDrag'] +export const inject = ['slots', 'fileDrag', 'menus'] /** * Register composer attachments and the shared gallery with its domain adapters. @@ -19,7 +20,7 @@ export const inject = ['slots', 'fileDrag'] export function apply(ctx: ClientContext): void { ctx.slots.registerFactory({ name: 'conversation.images.content', scope: 'root', locale: 'conversation', - inject: () => ({ fileDrag: ctx.fileDrag.actions }), + inject: () => ({ fileDrag: ctx.fileDrag.actions, openImageMenu: (source: import('@deepseek-ai/dsh-client-ui-context-menu/client').MenuTargetMap['image'], point: { x: number; y: number }) => { ctx.menus.open({ kind: 'image', value: source }, point) } }), }, MessageImagesContent) ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({ name: 'conversation.input.attachments', 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 09f0db62cb..547a61bdee 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 @@ -29,6 +29,7 @@ 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) 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 7d0ee96bea..42846d2a5d 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -277,6 +277,7 @@ 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) await runtime.mount(attachmentPlugin) const images = [{ preview: { url: 'blob:labels', name: 'preview.png' } }] const loadImage = vi.fn(async () => 'blob:unused') @@ -318,6 +319,7 @@ 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) await runtime.mount(attachmentPlugin) await runtime.sessions.add({ id: 'message-images-test' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test')) diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index a14099543e..4e9ac88e89 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -21,6 +21,7 @@ async function bench() { await ctx.fiber.dispose() }) ctx.provide('sessions', sessions) + ctx.provide('menus', { open: vi.fn() } as never) ctx.provide('locale', new LocaleRuntime(ctx)) await ctx.plugin(SlotRegistry).await() ctx.slots.register({ @@ -47,7 +48,7 @@ describe('attachment plugin', () => { it('registers all entries and removes them with the plugin fiber', async () => { const { ctx, fiber, registerFactory } = await bench() - expect(inject).toEqual(['slots', 'fileDrag']) + expect(inject).toEqual(['slots', 'fileDrag', 'menus']) 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 1b7d6e0129..859b7bba64 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -43,6 +43,9 @@ }, { "path": "../../util/file-drag" + }, + { + "path": "../ui-context-menu" } ] } diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index d3e5dc6506..c0ebf7c688 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -21,8 +21,8 @@ en: cbf1f0a2205046a5 zh: 2e59d0815351349f /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: - en: 41dcd70e14ebe9cc - zh: ededead572b59037 + en: 5ada67393e11644f + zh: 30f6538942dd742b /deepseek-ai-dsh-client-ui-chat/turn-token-usage: en: 059eef868161efe4 zh: 52ecc8e682d4c630 @@ -36,8 +36,8 @@ en: a14c74ed7de0ef9c zh: d5e618b2ef809150 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 009ecb4fecd4ee9e - zh: ec2714337a1102c5 + en: 3c437cbcc09621d8 + zh: 17830fa01842143a /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b5dc85a0f9..d7eae6efa9 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -61,6 +61,8 @@ 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 and tool-file context gestures use the recorded target path rather than its display label. Shift+F10 and the context key open the focused reference menu; images and editable controls keep their own handlers. + ----- @@ -180,6 +182,8 @@ The Chat view detects selection and context-menu gestures on business nodes mark `ctx.uiConversation.interactions` dispatches eligible gestures to plugin registrations and supplies their component to the Chat view. The action surface is absolutely positioned inside the existing Chat column, above the composer seat and its dock. Its wrapper preserves the component's outer stroke and shadow; components own internal scrolling. Scrolling the selection outside that visible region temporarily hides the surface; returning restores it. Chat owns viewport visibility, placement, outside pointer presses, Escape, new selections, source invalidation, and disposal; explicit dismissal preserves browser selection and layout changes do not reopen the surface. [Message quotes](../../session/message-quotes/README.md) owns quotation eligibility and insertion. +Markdown files, links, and images forward explicit right-click targets to [ui-context-menu](../ui-context-menu/README.md). File targets retain their working directory and line; ordinary link clicks retain the configured opening preference. + ## Model Experience diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 06d1d924b2..383942e9b0 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -61,6 +61,8 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要表达。每个终止轮次都渲染自己的红点行;欠费失败的行使用中立的 `message.failure.quota` 文案,而不是提供方消息。新追加的 `QUOTA` 或 `ACCOUNT_QUOTA` 产生的瞬时提示来自本包注册在 `shell.overlay` 的全局条目,它不随 Chat 面板消失:该条目把唯一一条实时提示交给 `shell.quota-notice` 链,并在无人接管时回退到自己的警告 Toast,接管该错误码的条目会替换该回退。只有本 Client 已绑定并已物化的 Session 会发布提示;它从未打开的 Session 中的欠费不会发布。更新的提示会替换当前提示,除非认领条目用 `keepOpen()` 保持住当前提示:该调用返回一个由调用方持有、必须在卸载时调用的释放函数,只要存在保持,认领条目就继续挂载,后续提示被丢弃,释放后恢复后续提示但不重放已丢弃的提示。回退 Toast 自身没有延后策略:当 Desktop 账号的不透明原生 Platform 页盖住本页时,它仍会在下方运行,其显示计时器可能在不可见时走完并自行撤下提示,因此只留下持久化失败行。释放只移除自己的保持,因此它在关闭或更新的保持之后执行时不会影响更新的保持。关闭与退出登录会清空所有保持;被丢弃的提示不排队,其对应的持久化失败行仍会渲染。历史替换和分页不会发布提示。中间重试不会创建终止行;输出 token 上限使用琥珀色警告点。重启恢复以 `interrupted` 结束的轮次会渲染本地化的停止行,说明 DSH 进程在轮次中途退出,不显示代码标签,其过程摘要显示“已停止”。 +引用与工具文件的右键操作使用记录的目标路径,而非显示标签。Shift+F10 和菜单键打开已聚焦引用的菜单;图片与可编辑控件保留自己的处理器。 + ----- @@ -180,6 +182,8 @@ Chat view 在标记了 `data-text-interaction-source` 的业务节点上检测 `ctx.uiConversation.interactions` 将有效手势分发给插件注册项,并把对应组件交给 Chat view。操作展示位置在已有 Chat 列内绝对定位,限制在 composer seat 及其 dock 上方。外层容器保留组件的外描边与阴影,内部滚动由组件负责。选区滚出该可见范围时暂时隐藏操作,滚回后恢复。Chat 负责视口可见性、定位、外部指针按下、Escape、新选区、来源失效与卸载清理;主动关闭保留浏览器选区,布局变化不会重新打开操作。[Message quotes](../../session/message-quotes/README.zh.md) 负责引用的接管条件和插入。 +Markdown 文件、链接和图片将明确的右键目标转发给 [ui-context-menu](../ui-context-menu/README.zh.md)。文件目标保留工作目录和行号;普通链接点击仍遵循配置的打开偏好。 + ## 模型体验 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 398661a4ff..87c8185bcd 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -42,7 +42,8 @@ "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-workspace", - "@deepseek-ai/dsh-client-ui-file-drag" + "@deepseek-ai/dsh-client-ui-file-drag", + "@deepseek-ai/dsh-client-ui-context-menu" ], "platform": "web" } @@ -97,7 +98,8 @@ "react-dom": "^18.2.0", "@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-file-drag": "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 fcbc44d9be..aa8e619bed 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -1,3 +1,4 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Register the Chat Conversation target, renderers, stats, and details surface. */ import type {} from '@deepseek-ai/dsh-client-ui-file-drag/client' import type {} from '@deepseek-ai/dsh-client-product-analytics/client' @@ -75,6 +76,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ + 'menus', 'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale', 'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', 'fileDrag', ] @@ -276,6 +278,20 @@ export function apply(ctx: Context): void { else ctx.sidebarRight.openResource(url, { params: { line: options.line } }) await Promise.resolve() }, + openContextMenu: (target, point) => { + 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: 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, + ...(target.name === undefined ? {} : { name: target.name }), + ...(target.mediaType === undefined ? {} : { mediaType: target.mediaType }), + ...(target.path === undefined ? {} : { path: path(target.path) }) } }, point); break + } + }, openExternalLink: (url) => { if (linkOpening.getSnapshot() === 'sidebar' && ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { ctx.sidebarRight.openTab('browser', { params: { url } }) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index f1a0556b9f..e543bcce19 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -1,6 +1,7 @@ // An enclosing `[data-conversation-scroll]` owns scrolling when present; // otherwise this view owns it. Each row subscribes to one stable node key. +import { openChatContextMenu, chatContextKey } from './context-menu.ts' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { NodeKey, RenderEntry, @@ -60,7 +61,7 @@ const MOTION_TAIL_MS = 800 */ export function ChatView({ useSession, useChat, useConversation, useSessions, renderSlot, - sessionId, openFile, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, + sessionId, openFile, openExternalLink, openContextMenu, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, usePresentation, useProjection, useInteractionIds, dispatchInteraction, t, fileDrag, }: ChatViewSlotProps) { const order = useChat(s => s.order) @@ -214,6 +215,8 @@ export function ChatView({
{ openChatContextMenu(event, openContextMenu) }} + onKeyDown={(event) => { chatContextKey(event, openContextMenu) }} data-chat-motion={deferCompletedTurns && scroll.initialized && (running || motionTail || pendingInputs.length > 0) ? '' : undefined}> {openState === 'loading' &&
{t('chat.loadingHistory')}
} @@ -229,7 +232,8 @@ export function ChatView({
)} - + {renderSlot('conversation.chat.flow', { entries, pendingInputs, lastInputTurn, deferCollapse, cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, fileMentions, diff --git a/packages/client/ui-chat/src/client/chat/context-menu.ts b/packages/client/ui-chat/src/client/chat/context-menu.ts new file mode 100644 index 0000000000..74fafe4992 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/context-menu.ts @@ -0,0 +1,38 @@ +/** Delegate reference and tool-file gestures to the shared context-menu capability. */ +import type { KeyboardEvent, MouseEvent, SyntheticEvent } from 'react' +import type { ChatViewInjected } from '../contract/slots.ts' + +function show(event: SyntheticEvent, open: ChatViewInjected['openContextMenu'], point?: { x: number; y: number }): void { + if (event.defaultPrevented || !(event.target instanceof Element)) return + if (event.target.closest('img, input, textarea, [contenteditable="true"], [role="menu"]')) return + const source = event.target.closest('[data-file-path], a[href]') + if (source === null || !event.currentTarget.contains(source)) return + const path = source.getAttribute('data-file-path') + const url = source.getAttribute('href') + if (!path && (!url || !/^https?:\/\//iu.test(url))) return + event.preventDefault(); event.stopPropagation() + const rect = source.getBoundingClientRect() + const position = point ?? { x: rect.left, y: rect.bottom } + source.focus({ preventScroll: true }) + if (path) open({ kind: 'file', path }, position) + else if (url) open({ kind: 'url', url }, position) +} + +/** + * Open the menu of a rendered file reference or URL. + * @param event - bubbled pointer gesture. + * @param open - captured Session menu callback. + */ +export function openChatContextMenu(event: MouseEvent, open: ChatViewInjected['openContextMenu']): void { + show(event, open, event.clientX === 0 && event.clientY === 0 ? undefined : { x: event.clientX, y: event.clientY }) +} + +/** + * Open a focused reference with the context key or Shift+F10. + * @param event - keyboard menu request. + * @param open - captured Session menu callback. + */ +export function chatContextKey(event: KeyboardEvent, open: ChatViewInjected['openContextMenu']): void { + if (event.repeat || event.nativeEvent.isComposing || event.ctrlKey || event.altKey || event.metaKey) return + if (event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)) show(event, open) +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 948a546689..f9ad22f371 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -1,3 +1,4 @@ +import type { MarkdownDelegate } from '@deepseek-ai/dsh-client-ui-primitives' /** Chat-owned Slot declarations and composed component props. */ import type { RefObject } from 'react' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -300,6 +301,7 @@ 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 + openContextMenu: NonNullable /** Dispatch one gesture to the Conversation plugin registry. */ dispatchInteraction: (gesture: TextInteractionGesture) => string | null hooks: { diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 275d76b96b..3fc46a4cb3 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -1,3 +1,4 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Browser Chat target plugin. */ export { apply, inject } from './apply.ts' export type {} from './conversation-nodes/assistant.ts' 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 557b9e2a04..9e0e698388 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -1,4 +1,5 @@ import { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client' // @vitest-environment jsdom /** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -100,6 +101,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' }, diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index ab256ad15b..6f7d488aaa 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { act, fireEvent, render } from '@testing-library/react' @@ -75,6 +76,7 @@ async function bench() { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' }, 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 cf2516d005..f9e2548ed4 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -469,6 +469,7 @@ function makeHarness( const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: HarnessStandardProps = { fileDrag, + openContextMenu: vi.fn(), renderFactorySlot, usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, diff --git a/packages/client/ui-chat/tests/context-menu.client.spec.tsx b/packages/client/ui-chat/tests/context-menu.client.spec.tsx new file mode 100644 index 0000000000..d51b53cafa --- /dev/null +++ b/packages/client/ui-chat/tests/context-menu.client.spec.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { chatContextKey, openChatContextMenu } from '../src/client/chat/context-menu.ts' + +afterEach(cleanup) + +it('copies the recorded path target rather than a tool or reference display label, and preserves URL query and fragment', () => { + const open = vi.fn() + const view = render(
{ openChatContextMenu(event, open) }}> + + Website +
) + fireEvent.contextMenu(view.getByRole('button'), { clientX: 50, clientY: 70 }) + expect(open).toHaveBeenLastCalledWith({ kind: 'file', path: '/workspace/output.txt' }, { x: 50, y: 70 }) + fireEvent.contextMenu(view.getByRole('link'), { clientX: 80, clientY: 90 }) + expect(open).toHaveBeenLastCalledWith({ kind: 'url', url: 'https://example.com/path?q=1#fragment' }, { x: 80, y: 90 }) +}) + +it('opens keyboard menus from their focused reference and leaves images, editors, and unrelated gestures alone', () => { + const open = vi.fn() + const view = render(
{ chatContextKey(event, open) }} + onContextMenu={(event) => { openChatContextMenu(event, open) }}> + + Image +