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..458cbcc733 --- /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: f04341778de57140 + zh: 12147060ea28feb1 +/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..da02632cf2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md @@ -0,0 +1,29 @@ +# 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 the destination returned by a native save. + +The menu plugin contributes `useContextMenu` through the existing Slot root binding. Its ordinary `getTriggerProps` factory centralizes DOM gestures while business owners still provide captured targets. Plain children receive props or existing delegates; a separate React Context or utility package would duplicate the established lifetime and injection mechanism. + +## Alternatives considered + +**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..cb0b3c5f3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md @@ -0,0 +1,29 @@ +# 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 文件,或原生保存返回的目标路径。 + +菜单插件通过现有 Slot root 绑定贡献 `useContextMenu`。普通函数 `getTriggerProps` 统一处理 DOM 手势,业务所有者继续提供捕获的目标。普通子组件通过 props 或现有 delegate 接收能力;独立 React Context 或工具包会重复已有生命周期与注入机制。 + +## 考虑过的替代方案 + +**仅使用 React Slot。** 它们支持任意视觉内容,但无法提供独立于展示方式的动作模型。现有组件贡献仍可使用它们。 + +**在中心位置按内置目标类型分支。** 这种方式会使每个新增的插件目标都要求修改菜单实现。声明合并与注册式提供方让目标特有行为保留在其所属插件。 + +**优先实现原生菜单。** 这会重复已有的键盘、定位和关闭实现,同时无法容纳仅提供组件的旧条目。Desktop 同样使用共享 Web 渲染器;其他渲染器可以独立消费纯数据动作视图。 + +## 影响 + +打开菜单时捕获目标和贡献集合。关闭取消元数据任务;插件卸载撤回该提供方的动作。已选动作自行负责执行,并须重新检查可变资源。旧 React 条目不参与纯数据解析,因此原生渲染不能假定功能完全一致。注册表测试覆盖这些生命周期;浏览器场景覆盖选择不变、原始字节以及嵌套浮层关闭。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index e7fced6b8e..1c55529b1c 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: db5e898140a73d53 + zh: 80a9d58c74b4bb24 /deepseek-harness-desktop/low-level-development-overrides: en: 8264b135871e8978 zh: 0939aafb1b2961fc diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f961a72a92..89755436cb 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -353,6 +353,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. Completion returns the destination path to that frame; revealing it uses the existing Host path-opening API without a separate reveal IPC or saved-file history. Downloads use Chromium’s URL download and native save-dialog APIs; `DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS` bounds startup until Chromium creates the download (300000 ms); the native save dialog has no application deadline. Clipboard-only remote image reads omit credentials, validate every redirect, reject the application Host and its loopback aliases, stop on main-frame navigation, and use `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS` (30000 ms) and `DSH_DESKTOP_IMAGE_READ_MAX_BYTES` (67108864 bytes). Image copying uses the renderer Clipboard API after PNG conversion. Other reachable HTTP(S) destinations follow deployment network isolation. [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 a7500d5d28..3df2783476 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -353,6 +353,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)保留这些限制;生产发布要求保持不变。 +原生原始文件操作仅接受可信应用页面。完成后向该页面返回目标路径;显示位置复用现有 Host 路径打开 API,不提供独立 reveal IPC,也不保留已保存文件历史。下载使用 Chromium URL 下载和原生保存对话框 API;`DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS` 限定 Chromium 创建下载前的启动期限(300000 毫秒),不限制原生保存对话框的停留时间。仅剪贴板使用的远程图片读取不携带凭据,逐次校验重定向,拒绝应用 Host 及其回环别名,在主框架导航时终止,并使用 `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS`(30000 毫秒)与 `DSH_DESKTOP_IMAGE_READ_MAX_BYTES`(67108864 字节)。图片复制在转换为 PNG 后使用渲染进程的 Clipboard API。其他可达的 HTTP(S) 目标遵循部署侧网络隔离。[文件操作](../../packages/client/ui-file-actions/README.zh.md) 定义保存和剪贴板行为。 + ## 底层开发覆盖项 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 081a90ead3..40bdfefe27 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -88,6 +88,7 @@ "@deepseek-ai/dsh-plugin-manager": "workspace:*", "zod": "4.4.3", "@deepseek-ai/dsh-util-file-drag": "workspace:*", + "@deepseek-ai/dsh-client-ui-file-actions": "workspace:*", "tencentcloud-sdk-nodejs-teo": "4.1.330" } } diff --git a/apps/desktop/src/file-actions.ts b/apps/desktop/src/file-actions.ts new file mode 100644 index 0000000000..45c4a25fc1 --- /dev/null +++ b/apps/desktop/src/file-actions.ts @@ -0,0 +1,192 @@ +/** Trusted-window Chromium downloads and clipboard fallback reads. */ +import { app, ipcMain, session } from 'electron' +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import { closeSync, openSync, rmSync } from 'node:fs' +import { extname, join } from 'node:path' +import { resolveDragFileName } from '@deepseek-ai/dsh-util-file-drag' +import { randomUUID } from 'node:crypto' +import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types' +import { DESKTOP_IPC } from './ipc.ts' +import { resolveDurationMs } from './duration-env.ts' + +/** @param value - untrusted IPC input. @returns captured URL and destination policy. */ +function saveRequest(value: unknown): { url: URL; name?: string; mediaType?: string; mode: 'download' | 'saveAs' } { + if (typeof value !== 'object' || value === null + || !('url' in value) || typeof value.url !== 'string' + || ('name' in value && typeof value.name !== 'string') + || ('mediaType' in value && typeof value.mediaType !== 'string') + || !('mode' in value) || (value.mode !== 'download' && value.mode !== 'saveAs')) throw new Error('Invalid file save request') + const url = new URL(value.url) + if (!['https:', 'http:', 'blob:', 'data:'].includes(url.protocol) || url.username || url.password) throw new Error('Unsupported file source') + return { url, mode: value.mode, + ...'name' in value ? { name: value.name as string } : {}, + ...'mediaType' in value ? { mediaType: value.mediaType as string } : {}, + } +} + +/** + * Install trusted-window original-file operations. + * @param assertOwner - rejects shell pages, subframes, and unowned windows. + * @param hostUrl - current application Host excluded from image reads. + */ +export function installFileActions( + assertOwner: (event: IpcMainInvokeEvent) => BrowserWindow, hostUrl: () => string | undefined = () => undefined, +): void { + 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 pending = new Map + finish: (result: FileSaveResult | Error) => void + started: () => void + item?: Electron.DownloadItem + }>() + session.defaultSession.on('will-download', (_event, item, contents) => { + const url = item.getURLChain()[0] + if (url === undefined || !new URL(url).hash.startsWith('#dsh-download=')) return + const entry = pending.get(url) + if (entry === undefined || entry.owner.webContents !== contents) { item.cancel(); return } + entry.started() + entry.item = item + let reserved: string | undefined + try { + assertOwner(entry.event) + const name = resolveDragFileName({ + name: entry.request.name ?? item.getFilename(), + mediaType: item.getMimeType() || entry.request.mediaType || '', + }) + let destination = join(app.getPath('downloads'), name) + if (entry.request.mode === 'saveAs') { + item.setSaveDialogOptions({ defaultPath: destination, properties: ['showOverwriteConfirmation', 'createDirectory'] }) + } else { + const extension = extname(destination) + const stem = destination.slice(0, destination.length - extension.length) + for (let index = 0; ; index++) { + destination = index === 0 ? `${stem}${extension}` : `${stem} (${index})${extension}` + try { closeSync(openSync(destination, 'wx')); break } + catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') continue + throw error + } + } + reserved = destination + item.setSavePath(destination) + } + item.once('done', (_event, state) => { + try { + if (state !== 'completed') { + if (reserved !== undefined) rmSync(reserved, { force: true }) + entry.finish(state === 'cancelled' ? { status: 'cancelled' } : new Error('File download interrupted')) + return + } + if (!pending.has(url)) return + assertOwner(entry.event) + entry.finish({ status: 'saved', path: item.getSavePath() }) + } catch (error) { entry.finish(error instanceof Error ? error : new Error('File download unavailable')) } + }) + } catch (error) { + item.cancel() + try { if (reserved !== undefined) rmSync(reserved, { force: true }) } + catch (cleanupError) { + entry.finish(new AggregateError([error, cleanupError], 'File download failed and its partial file could not be removed')) + return + } + entry.finish(error instanceof Error ? error : new Error('File download unavailable')) + } + }) + const downloadTimeoutMs = resolveDurationMs(process.env, 'DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', 300_000) + ipcMain.handle(DESKTOP_IPC.fileSave, (event, value: unknown): Promise => { + const owner = assertOwner(event) + const request = saveRequest(value) + // Fragments do not change the fetched resource; each native request has its own completion identity. + request.url.hash = `dsh-download=${randomUUID()}` + const url = request.url.href + return new Promise((resolve, reject) => { + const contents = owner.webContents + const finish = (result: FileSaveResult | Error): void => { + if (!pending.delete(url)) return + clearTimeout(timer) + owner.removeListener('closed', cancel) + contents.removeListener('did-start-navigation', navigate) + if (result instanceof Error) reject(result) + else resolve(result) + } + const cancel = (): void => { + const item = pending.get(url)?.item + finish({ status: 'cancelled' }) + item?.cancel() + } + const navigate = (_event: Electron.Event, _url: string, inPlace: boolean, mainFrame: boolean): void => { + if (mainFrame && !inPlace) cancel() + } + const timer = setTimeout(() => { + const item = pending.get(url)?.item + finish(new Error('File download timed out')) + item?.cancel() + }, downloadTimeoutMs) + pending.set(url, { event, owner, request, finish, started: () => { clearTimeout(timer) } }) + owner.once('closed', cancel) + contents.on('did-start-navigation', navigate) + try { contents.downloadURL(url) } + catch (error) { finish(error instanceof Error ? error : new Error('File download unavailable')) } + }) + }) + ipcMain.handle(DESKTOP_IPC.clipboardImageRead, async (event, value: unknown) => { + const owner = assertOwner(event) + if (typeof value !== 'string') throw new Error('Invalid image URL') + const checkedUrl = (value: string): URL => { + const url = new URL(value) + const host = hostUrl() + const application = host === undefined ? undefined : new URL(host) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password + || application !== undefined && url.port === application.port + && (url.hostname === application.hostname || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) { + throw new Error('Invalid image URL') + } + return url + } + let url = checkedUrl(value) + const contents = owner.webContents + const lifetime = new AbortController() + const abort = (): void => { lifetime.abort() } + owner.once('closed', abort) + const navigate = (_event: Electron.Event, _url: string, inPlace: boolean, mainFrame: boolean): void => { + if (mainFrame && !inPlace) abort() + } + contents.on('did-start-navigation', navigate) + try { + const signal = AbortSignal.any([lifetime.signal, AbortSignal.timeout(timeoutMs)]) + let response: Response + for (let redirects = 0; ; redirects++) { + response = await fetch(url, { signal, redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer' }) + if (![301, 302, 303, 307, 308].includes(response.status)) break + await response.body?.cancel() + const location = response.headers.get('location') + if (redirects >= 20 || location === null) throw new Error('Invalid image redirect') + url = checkedUrl(new URL(location, url).href) + } + 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) + contents.removeListener('did-start-navigation', navigate) + } + }) +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index e62a84f0a1..a3c5f5d935 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', + clipboardImageRead: 'dsh-desktop:clipboard-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 4770fe83bf..674221b671 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. */ @@ -826,6 +827,7 @@ async function main(): Promise { || !event.senderFrame.url.startsWith('dsh-app://app/')) throw new Error('Rejected Platform command') return owner } + installFileActions(assertMainApplication, () => hostUrl) 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..26266b4f1c 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,11 @@ 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, + readClipboardImage: url => ipcRenderer.invoke(DESKTOP_IPC.clipboardImageRead, 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..972f64e7ee --- /dev/null +++ b/apps/desktop/tests/file-actions.spec.ts @@ -0,0 +1,213 @@ +/** 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 { IpcMainInvokeEvent } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +import { installFileActions } from '../src/file-actions.ts' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const electron = vi.hoisted(() => ({ + app: { getPath: vi.fn() }, ipcMain: { handle: vi.fn() }, session: { defaultSession: { on: vi.fn() } }, +})) +vi.mock('electron', () => electron) +const roots: string[] = [] +const owners: EventEmitter[] = [] +afterEach(async () => { + for (const owner of owners.splice(0)) owner.emit('closed') + vi.useRealTimers(); vi.unstubAllEnvs() + 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 } + +function harness(root: string) { + const owner = Object.assign(new EventEmitter(), { + webContents: Object.assign(new EventEmitter(), { downloadURL: vi.fn<(url: string) => void>() }), isDestroyed: () => false, + }) + owners.push(owner) + const assertOwner = vi.fn(() => owner as never) + 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) + } + const item = (mime = 'image/gif', url = owner.webContents.downloadURL.mock.lastCall![0]) => { + let destination = '' + const download = Object.assign(new EventEmitter(), { + getURLChain: () => [url], + getFilename: () => 'original.gif', getMimeType: () => mime, getSavePath: () => destination, + setSavePath: vi.fn((path: string) => { destination = path }), + setSaveDialogOptions: vi.fn(), cancel: vi.fn(), + }) + const willDownload = electron.session.defaultSession.on.mock.calls[0]![1] as + (event: object, item: object, contents: object) => void + willDownload({}, download, owner.webContents) + return download + } + const save = async (mode: 'download' | 'saveAs' = 'download') => { + const pending = invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/original.gif', name: 'image.gif', mode }) + const download = item() + if (mode === 'download') await writeFile(download.getSavePath(), new Uint8Array([0, 128, 255])) + download.emit('done', {}, 'completed') + return pending + } + return { owner, assertOwner, invoke, item, save } +} + +it('returns the completed destination and releases download listeners', async () => { + const root = await directory(), f = harness(root) + await expect(f.save()).resolves.toEqual({ status: 'saved', path: join(root, 'image.gif') }) + expect(f.owner.listenerCount('closed')).toBe(0) + expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0) +}) + +it.each(['file:///private/data', 'dsh-app://app/api/private', 'javascript:alert(1)'])('rejects unsupported URL %s', async (url) => { + const root = await directory(), f = harness(root) + await expect(f.invoke(DESKTOP_IPC.fileSave, { url, mode: 'download' })).rejects.toThrow('Unsupported') + expect(await readdir(root)).toEqual([]) +}) + +it('retains original downloads and reserves distinct paths until Chromium finishes', async () => { + const root = await directory(), f = harness(root) + await writeFile(join(root, 'image.gif'), 'previous') + await f.save() + expect(await readFile(join(root, 'image.gif'), 'utf8')).toBe('previous') + expect(await readFile(join(root, 'image (1).gif'))).toEqual(Buffer.from([0, 128, 255])) +}) + +it.each(['cancelled', 'interrupted'] as const)('reports native %s without a completed path', async (state) => { + const root = await directory(), f = harness(root) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'wrong.png', mode: 'download' }) + const download = f.item('image/webp') + expect(download.setSavePath).toHaveBeenCalledWith(join(root, 'wrong.webp')) + download.emit('done', {}, state) + if (state === 'cancelled') await expect(pending).resolves.toEqual({ status: 'cancelled' }) + else await expect(pending).rejects.toThrow('interrupted') + expect(await readdir(root)).toEqual([]) +}) + +it('uses the native save dialog and waits for completion before reporting success', async () => { + const root = await directory(), f = harness(root) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'photo.png', mode: 'saveAs' }) + const download = f.item('image/jpeg') + expect(download.setSavePath).not.toHaveBeenCalled() + expect(download.setSaveDialogOptions).toHaveBeenCalledWith({ defaultPath: join(root, 'photo.jpg'), properties: ['showOverwriteConfirmation', 'createDirectory'] }) + download.emit('done', {}, 'cancelled') + await expect(pending).resolves.toEqual({ status: 'cancelled' }) + f.assertOwner.mockImplementation(() => { throw new Error('Rejected owner') }) + await expect(f.invoke(DESKTOP_IPC.fileSave, {})).rejects.toThrow('Rejected owner') +}) + +it('cancels pending downloads when the owning document changes', async () => { + const f = harness(await directory()) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' }) + const download = f.item() + f.owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/', false, true) + await expect(pending).resolves.toEqual({ status: 'cancelled' }) + expect(download.cancel).toHaveBeenCalledOnce() + download.emit('done', {}, 'cancelled') + expect(f.owner.listenerCount('closed')).toBe(0) +}) + +it('matches overlapping downloads of the same source to their captured requests', async () => { + const root = await directory(), f = harness(root) + const first = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'first.gif', mode: 'download' }) + const firstUrl = f.owner.webContents.downloadURL.mock.lastCall![0] + const second = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'second.gif', mode: 'saveAs' }) + const secondItem = f.item() + const firstItem = f.item('image/gif', firstUrl) + expect(firstItem.setSavePath).toHaveBeenCalledWith(join(root, 'first.gif')) + expect(secondItem.setSaveDialogOptions.mock.lastCall?.[0]).toMatchObject({ defaultPath: join(root, 'second.gif') }) + secondItem.emit('done', {}, 'cancelled') + firstItem.emit('done', {}, 'completed') + await expect(first).resolves.toMatchObject({ status: 'saved' }) + await expect(second).resolves.toEqual({ status: 'cancelled' }) +}) + +it('retires timed-out downloads and cancels items arriving after their window closes', async () => { + vi.useFakeTimers(); vi.stubEnv('DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', '1000') + const root = await directory(), f = harness(root) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' }) + const rejected = expect(pending).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(1000) + await rejected + const download = f.item() + expect(download.cancel).toHaveBeenCalledOnce() + download.emit('done', {}, 'cancelled') + expect(await readdir(root)).toEqual([]) + expect(f.owner.listenerCount('closed')).toBe(0) + expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0) + + const late = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' }) + f.owner.emit('closed') + await expect(late).resolves.toEqual({ status: 'cancelled' }) + const stale = f.item() + expect(stale.cancel).toHaveBeenCalledOnce() + expect(stale.setSavePath).not.toHaveBeenCalled() +}) + +it('allows the native save dialog to remain open beyond the download startup deadline', async () => { + vi.useFakeTimers(); vi.stubEnv('DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', '1000') + const f = harness(await directory()) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'saveAs' }) + const download = f.item() + await vi.advanceTimersByTimeAsync(2000) + expect(download.cancel).not.toHaveBeenCalled() + download.emit('done', {}, 'completed') + await expect(pending).resolves.toMatchObject({ status: 'saved' }) +}) + +it('reports a synchronous Chromium launch failure and releases pending listeners', async () => { + const f = harness(await directory()) + f.owner.webContents.downloadURL.mockImplementation(() => { throw new Error('Download unavailable') }) + await expect(f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' })).rejects.toThrow('Download unavailable') + expect(f.owner.listenerCount('closed')).toBe(0) + expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0) +}) + +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.clipboardImageRead, 'https://images.example/original.gif') + f.owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/', false, true) + 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-navigation')).toBe(0) +}) + +it('preserves pending downloads across iframe and same-document navigation', async () => { + const root = await directory(), f = harness(root) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'saveAs' }) + const download = f.item() + f.owner.webContents.emit('did-start-navigation', {}, 'https://example.org', false, false) + f.owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/#tab', true, true) + download.setSavePath(join(root, 'chosen.gif')) + download.emit('done', {}, 'completed') + await expect(pending).resolves.toEqual({ status: 'saved', path: join(root, 'chosen.gif') }) + expect(download.cancel).not.toHaveBeenCalled() +}) + +it('rejects redirects to the application Host', async () => { + const root = await directory() + const owner = Object.assign(new EventEmitter(), { + webContents: Object.assign(new EventEmitter(), { downloadURL: vi.fn<(url: string) => void>() }), isDestroyed: () => false, + }) + installFileActions(() => owner as never, () => 'http://127.0.0.1:19321') + const handler = (name: string) => electron.ipcMain.handle.mock.calls.find(([channel]) => channel === name)![1] as + (event: IpcMainInvokeEvent, value: unknown) => Promise + const fetchImage = vi.fn(async () => new Response(null, { status: 302, headers: { location: 'http://localhost:19321/api/private' } })) + vi.stubGlobal('fetch', fetchImage) + const event = {} as IpcMainInvokeEvent + await expect(handler(DESKTOP_IPC.clipboardImageRead)(event, 'https://images.example/photo')).rejects.toThrow('Invalid image URL') + expect(fetchImage).toHaveBeenCalledOnce() + expect(await readdir(root)).toEqual([]) +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 20325642a2..0cb7aa3e96 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -317,6 +317,7 @@ vi.mock('electron', () => ({ }, Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, session: { defaultSession: { + on: vi.fn(), setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders }, } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index af9a9f226a..8001ae6fc2 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -38,6 +38,19 @@ 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 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 = { url: 'https://images.example/original.gif', name: 'image.gif', mode: 'download' as const } + await api.fileActions.save(request) + await api.fileActions.readClipboardImage('https://images.example/original.gif') + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ + [DESKTOP_IPC.fileSave, request], + [DESKTOP_IPC.clipboardImageRead, 'https://images.example/original.gif'], + ]) +}) + 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/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 7915248243..4ae8743e2e 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -87,6 +87,7 @@ vi.mock('electron', () => ({ net: { fetch: vi.fn() }, nativeTheme: state.nativeTheme, session: { defaultSession: { + on: vi.fn(), setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() }, } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, 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 44992641d5..7078ac8198 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -40,6 +40,9 @@ }, { "path": "../../packages/util/file-drag" + }, + { + "path": "../../packages/client/ui-file-actions" } ], "exclude": [ diff --git a/apps/web/tests/client-plugin-live.e2e.ts b/apps/web/tests/client-plugin-live.e2e.ts index 91f717363e..72a389f9c8 100644 --- a/apps/web/tests/client-plugin-live.e2e.ts +++ b/apps/web/tests/client-plugin-live.e2e.ts @@ -26,7 +26,7 @@ async function openInventory(page: Page, url: string) { return dialog } -it('places dynamic Session menu rows by order among the shipped ones and removes them with their fiber', async () => { +it('keeps ordered legacy Session menu rows before Archive and removes them with their fiber', async () => { const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] }) onTestFinished(() => scaffold.close()) const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) @@ -95,14 +95,16 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await observe('menu-open', false, true) const menu = page.getByRole('menu') await menu.waitFor() - expect(await menu.getByRole('separator').count()).toBe(1) + expect(await menu.getByRole('separator').count()).toBe(3) + const exportRow = menu.getByRole('menuitem', { name: 'Export session', exact: true }) + expect(await exportRow.locator('..').getByRole('separator').isVisible()).toBe(true) await compareOrRefreshGolden( SESSION_ACTION_EXPECTED, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode(), ) - await menu.getByRole('menuitem', { name: 'Export session' }).press('Enter') + await exportRow.press('Enter') await expect.poll(() => menu.count()).toBe(0) expect(await page.evaluate(() => ({ action: document.documentElement.dataset.sessionAction, @@ -194,13 +196,13 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await row.hover() await trigger.click() await menu.waitFor() - const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session', 'Copy Session ID'] + const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Archive session'] const remainingItems = menu.getByRole('menuitem') expect(await remainingItems.count()).toBe(remainingNames.length) for (const [index, name] of remainingNames.entries()) { expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1) } - expect(await menu.getByRole('separator').count()).toBe(0) + expect(await menu.getByRole('separator').count()).toBe(2) await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) await menu.getByRole('menuitem', { name: 'Copy Session ID', exact: true }).click() await expect.poll(() => menu.count()).toBe(0) diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts new file mode 100644 index 0000000000..9e350ec6d4 --- /dev/null +++ b/apps/web/tests/context-menus.e2e.ts @@ -0,0 +1,386 @@ +/** Context gestures against built plugins, seeded Sessions, and original files. */ +import { mkdir, 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, vi } 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 { 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.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', (platform) => { + 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 mkdir(join(scaffold.workspaceCwd, 'folder')) + await scaffold.ctx.workspaceRegistry.create(join(scaffold.workspaceCwd, 'folder'), 'Menu workspace') + 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() + const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' }) + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + page = await context.newPage() + 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', headers: { 'access-control-allow-origin': '*' } })) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto(scaffold.authenticatedUrl) + await page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' }).click() + await page.locator('[data-row-key="session:context-menu-other"]').click() + await page.locator('[data-row-key="session:context-menu-other"]').getByText('Menu background', { exact: true }).waitFor() + 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) + expect(names.slice(0, 5)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Show in folder']) + expect(names.at(-1)).toBe('Archive session') + expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true) + expect(await menu.getByRole('separator').count()).toBe(2) + expect(await menu.getByRole('menuitem', { name: 'Archive session', exact: true }).evaluate(row => + getComputedStyle(row.parentElement!.parentElement!).borderTopWidth)).toBe('0px') + const alignedSeparators = async () => { + const bounds = await menu.evaluate((node) => { + const row = node.querySelector('[role="menuitem"]')! + const rect = row.getBoundingClientRect() + const style = getComputedStyle(row) + return { + left: rect.left + Number.parseFloat(style.paddingLeft), + right: rect.right - Number.parseFloat(style.paddingRight), + lines: [...node.querySelectorAll('[role="separator"]')].map((line) => { + const rect = line.getBoundingClientRect() + return { left: rect.left, right: rect.right } + }), + } + }) + for (const line of bounds.lines) { + expect(line.left).toBeCloseTo(bounds.left, 1) + expect(line.right).toBeCloseTo(bounds.right, 1) + } + } + await alignedSeparators() + 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 alignedSeparators() + await background.getByRole('button', { name: /actions/i }).click() + expect(await menu.count()).toBe(0) + expect(await background.getAttribute('aria-selected')).toBe('false') + }) + + it('updates an open Session menu from another window’s shortcut preferences and still renames the captured row', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-live-shortcuts')) + const other = await page.context().newPage() + const key = 'dsh.keybindings.v1' + const original = await page.evaluate(key => localStorage.getItem(key), key) + try { + await other.goto(scaffold.authenticatedUrl) + await other.getByRole('button', { name: 'Search sessions', exact: true }).waitFor() + const background = page.locator('[data-row-key="session:context-menu-other"]') + await background.click({ button: 'right' }) + const item = page.getByRole('menuitem', { name: 'Rename', exact: true }) + await item.waitFor() + const popup = await page.getByRole('menu').elementHandle() + const initial = await item.getAttribute('aria-keyshortcuts') + await other.evaluate((key) => { + localStorage.setItem(key, JSON.stringify({ schemaVersion: 2, profiles: { + 'web:macos': { 'session.rename': { code: 'Comma', modifiers: ['primary', 'shift'] } }, + 'web:windows': { 'session.rename': { code: 'Comma', modifiers: ['primary', 'shift'] } }, + 'web:linux': { 'session.rename': { code: 'Comma', modifiers: ['primary', 'shift'] } }, + } })) + }, key) + await expect.poll(() => item.getAttribute('aria-keyshortcuts')).toBe(platform === 'MacIntel' ? 'Shift+Meta+,' : 'Control+Shift+,') + expect(await item.getAttribute('aria-keyshortcuts')).not.toBe(initial) + expect(await popup!.evaluate(element => element.isConnected)).toBe(true) + expect(await background.getAttribute('aria-selected')).toBe('false') + await item.click() + const rename = page.getByRole('dialog', { name: 'Rename session', exact: true }) + expect(await rename.getByLabel('Session name').inputValue()).toBe('Menu background') + await rename.getByRole('button', { name: 'Cancel', exact: true }).click() + expect(await background.getAttribute('aria-selected')).toBe('false') + await background.click({ button: 'right' }) + await item.waitFor() + await other.evaluate((key) => { + localStorage.setItem(key, JSON.stringify({ schemaVersion: 2, profiles: { + 'web:macos': { 'session.rename': null }, 'web:windows': { 'session.rename': null }, 'web:linux': { 'session.rename': null }, + } })) + }, key) + await expect.poll(() => item.getAttribute('aria-keyshortcuts')).toBeNull() + await other.evaluate(({ key, original }) => { + if (original === null) localStorage.removeItem(key) + else localStorage.setItem(key, original) + }, { key, original }) + await expect.poll(() => item.getAttribute('aria-keyshortcuts')).toBe(initial) + await page.keyboard.press('Escape') + } finally { + const cancel = page.getByRole('dialog', { name: 'Rename session', exact: true }).getByRole('button', { name: 'Cancel', exact: true }) + if (await cancel.isVisible()) await cancel.click() + await page.keyboard.press('Escape') + await other.evaluate(({ key, original }) => { + if (original === null) localStorage.removeItem(key) + else localStorage.setItem(key, original) + }, { key, original }) + await other.close() + } + }) + + it('shares Workspace menus and keeps folder gestures independent from expansion', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-workspace')) + const workspace = page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Menu workspace' }) + const expanded = await workspace.getAttribute('aria-expanded') + await workspace.click({ button: 'right' }) + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor() + const names = await menuLabels(menu) + expect(names).toEqual(['Rename', 'Show in folder', 'Delete workspace']) + expect(await menu.locator('svg[data-icon]').count()).toBe(3) + expect(await workspace.getAttribute('aria-expanded')).toBe(expanded) + await page.keyboard.press('Escape') + await workspace.getByRole('button', { name: /actions/i }).click() + await menu.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor() + expect(await menuLabels(menu)).toEqual(names) + await menu.getByRole('menuitem', { name: 'Rename', exact: true }).click() + await page.getByRole('dialog').waitFor() + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + await workspace.click({ button: 'right' }) + await menu.getByRole('menuitem', { name: 'Delete workspace', exact: true }).click() + await page.getByRole('dialog', { name: 'Delete workspace', exact: true }).waitFor() + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + await page.locator('[data-sidebar-right-expand]').click() + await page.locator('[data-sidebar-right-guide-entry="files"]').click() + await page.locator('[data-files-state="tree"]').waitFor() + const folder = page.locator('[data-files-entry="directory"]').filter({ hasText: 'folder' }).getByRole('button').first() + await folder.click({ button: 'right' }) + await menu.getByRole('menuitem', { name: 'Copy folder path', exact: true }).waitFor() + expect(await menuLabels(menu)).toEqual(['Show in folder', 'Copy folder path']) + expect(await folder.getAttribute('aria-expanded')).toBe('false') + await menu.getByRole('menuitem', { name: 'Copy folder path', exact: true }).click() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(join(scaffold.workspaceCwd, 'folder')) + await page.getByText('Copied', { exact: true }).waitFor() + await expect(names.join('\n') + '\n').toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/workspace.txt')) + }) + + 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') + expect(await page.getByRole('menuitem', { name: 'Save as…', exact: true }).count()).toBe(0) + expect(await page.getByRole('menuitem', { name: 'Download', exact: true }).evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('ArrowUp') + expect(await page.getByRole('menuitem', { name: 'Copy file path', exact: true }).evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Enter') + await expect.poll(() => 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: 'Download', exact: true }).waitFor() + expect(await page.getByRole('menuitem', { name: 'Save as…', exact: true }).count()).toBe(0) + const fileDownloadPromise = page.waitForEvent('download') + await page.getByRole('menuitem', { name: 'Download', exact: true }).click() + const fileDownload = await fileDownloadPromise + expect(fileDownload.suggestedFilename()).toBe('first.txt') + expect(await readFile(await fileDownload.path(), 'utf8')).toBe('FIRST\nSECOND\nTHIRD\n') + const started = page.getByRole('alert').filter({ hasText: 'Download started' }) + await started.waitFor() + expect(await started.getByRole('button', { name: /Show in/ }).count()).toBe(0) + 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) + const remoteDownloadPromise = page.waitForEvent('download') + await page.getByRole('menuitem', { name: 'Download', exact: true }).click() + const remoteDownload = await remoteDownloadPromise + expect(await readFile((await remoteDownload.path()))).toEqual(GIF) + await page.getByRole('img', { name: 'Remote', 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('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 }).waitFor() + expect(await page.getByRole('menuitem', { name: 'Close', exact: true }).locator('svg[data-icon]').count()).toBe(1) + 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() + expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true) + 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([]) + }) + + it('offers completed Desktop destinations as toast text actions without enabling remote-image menu reveal', async () => { + const native = await browser.newPage({ viewport: { width: 1291, height: 768 }, locale: 'en-US' }) + const opened = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath').mockResolvedValue({ opened: true }) + try { + const destinations = { download: join(scaffold.workspaceCwd, 'downloaded.gif'), saveAs: join(scaffold.workspaceCwd, 'saved.txt') } + await native.addInitScript(({ platform, destinations }) => { + Object.defineProperty(navigator, 'platform', { value: platform }) + Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1, fileActions: { + save: async ({ mode }: { mode: 'download' | 'saveAs' }) => ({ status: 'saved', path: destinations[mode] }), + } } }) + }, { platform, destinations }) + await native.route('https://images.example/original.gif', route => route.fulfill({ body: GIF, contentType: 'image/gif' })) + await native.goto(scaffold.authenticatedUrl) + await native.evaluate((platform) => { + document.documentElement.dataset.platform = platform === 'MacIntel' ? 'darwin' : platform === 'Win32' ? 'win32' : 'linux' + }, platform) + const group = native.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await native.locator('[data-row-key="session:context-menu-first"]').click() + await native.getByText('CONTEXT_MENU_READY', { exact: true }).waitFor() + for (const [mode, label, text, theme] of [ + ['download', 'Download', 'Download complete', 'light'], + ['saveAs', 'Save as…', 'Saved', 'dark'], + ] as const) { + await native.emulateMedia({ colorScheme: theme }) + const trigger = mode === 'download' + ? native.getByRole('img', { name: 'Remote', exact: true }) + : native.getByRole('button', { name: 'First file', exact: true }) + await trigger.click({ button: 'right' }) + await native.getByRole('menuitem', { name: label, exact: true }).click() + const notice = native.getByRole('alert').filter({ hasText: text }) + const action = notice.getByRole('button', { name: platform === 'MacIntel' ? 'Show in Finder' : 'Show in folder', exact: true }) + await action.click() + await notice.waitFor({ state: 'hidden' }) + await expect.poll(() => opened.mock.calls.at(-1)?.[0]).toEqual({ path: destinations[mode], action: 'reveal' }) + } + expect(opened).toHaveBeenCalledTimes(2) + await native.getByRole('img', { name: 'Remote', exact: true }).click({ button: 'right' }) + const reveal = native.getByRole('menuitem', { name: 'Show in folder', exact: true }) + await reveal.waitFor() + expect(await reveal.isDisabled()).toBe(true) + } finally { + await native.close() + opened.mockRestore() + } + }) +}) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 9625333287..fa6c518add 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -563,7 +563,7 @@ describe('web e2e: the composer model switch is the default for later sessions', for (const palette of headingPalettes) { expect(palette.headingFills, `${palette.platform}, dark=${String(palette.dark)}`) .toEqual(['rgba(0, 0, 0, 0)']) - expect(palette.menuFill).toBe(palette.dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)') + expect(palette.menuFill).toBe(palette.dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)') expect(palette.searchBorder).toBe('rgba(0, 0, 0, 0)') expect(palette.positions).toEqual(['sticky']) expect(palette.headingRadii).toEqual([palette.platform === 'darwin' ? '0px' : palette.optionRadius]) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 79a5ed4bdb..a3d2eb21e6 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -714,7 +714,7 @@ else process.exit(1); const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() expect(initialFilesClose).toBe(1) await filesTab.click({ button: 'right' }) - expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(1) + await page.locator('[data-context-menu][role="menu"]').waitFor() await page.keyboard.press('Escape') await addTab.waitFor({ state: 'visible' }) const initialAdd = await addTab.count() diff --git a/apps/web/tests/expected/client-plugin-live/session-actions.expected.md b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md index 51df861f77..93f241e0fc 100644 --- a/apps/web/tests/expected/client-plugin-live/session-actions.expected.md +++ b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md @@ -2,8 +2,10 @@ - menuitem "Pin session" - menuitem "Rename" - menuitem "Fork session" - - menuitem "Archive session" + - separator - menuitem "Copy Session ID" - separator - menuitem "Export session" - menuitem "Copy session ID" + - separator + - menuitem "Archive session" 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..a8090be657 --- /dev/null +++ b/apps/web/tests/expected/context-menus/image.txt @@ -0,0 +1,3 @@ +Copy image +Download +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..5468d20e51 --- /dev/null +++ b/apps/web/tests/expected/context-menus/session.txt @@ -0,0 +1,6 @@ +Pin session +Rename +Fork session +Copy Session ID +Show in folder +Archive session diff --git a/apps/web/tests/expected/context-menus/workspace.txt b/apps/web/tests/expected/context-menus/workspace.txt new file mode 100644 index 0000000000..93c401728b --- /dev/null +++ b/apps/web/tests/expected/context-menus/workspace.txt @@ -0,0 +1,3 @@ +Rename +Show in folder +Delete workspace diff --git a/apps/web/tests/expected/menu-material/materials.expected.json b/apps/web/tests/expected/menu-material/materials.expected.json index 301564bef3..b0abc51804 100644 --- a/apps/web/tests/expected/menu-material/materials.expected.json +++ b/apps/web/tests/expected/menu-material/materials.expected.json @@ -1,24 +1,24 @@ { "web-light": { - "fill": "rgba(248, 249, 250, 0.58)", + "fill": "rgba(252, 253, 254, 0.7)", "blur": "blur(40px) saturate(1.5)", "radius": "16px", "shadow": "rgba(0, 0, 0, 0.04) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px" }, "web-dark": { - "fill": "rgba(67, 69, 74, 0.45)", + "fill": "rgba(48, 49, 54, 0.64)", "blur": "blur(40px) saturate(1.5)", "radius": "16px", "shadow": "rgba(255, 255, 255, 0.16) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px" }, "darwin-light": { - "fill": "rgba(248, 249, 250, 0.58)", + "fill": "rgba(252, 253, 254, 0.7)", "blur": "blur(40px) saturate(1.5)", "radius": "16px", "shadow": "rgba(0, 0, 0, 0.04) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px" }, "darwin-dark": { - "fill": "rgba(67, 69, 74, 0.45)", + "fill": "rgba(48, 49, 54, 0.64)", "blur": "blur(40px) saturate(1.5)", "radius": "16px", "shadow": "rgba(255, 255, 255, 0.16) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px" diff --git a/apps/web/tests/expected/menu-material/session-menu.expected.md b/apps/web/tests/expected/menu-material/session-menu.expected.md index ebb1786386..c7c535b9e4 100644 --- a/apps/web/tests/expected/menu-material/session-menu.expected.md +++ b/apps/web/tests/expected/menu-material/session-menu.expected.md @@ -2,5 +2,7 @@ - menuitem "Pin session" - menuitem "Rename" - menuitem "Fork session" - - menuitem "Archive session" + - separator - menuitem "Copy Session ID" + - separator + - menuitem "Archive session" diff --git a/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md index c8ab6426ab..16362fff3a 100644 --- a/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md +++ b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md @@ -1,2 +1,3 @@ - menu: - menuitem "Close" + - menuitem "Close Other Tabs" [disabled] diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md index ebb1786386..6724e2373d 100644 --- a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -2,5 +2,8 @@ - menuitem "Pin session" - menuitem "Rename" - menuitem "Fork session" - - menuitem "Archive session" + - separator - menuitem "Copy Session ID" + - menuitem "Show in Finder" + - separator + - menuitem "Archive session" diff --git a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js index 1c884c568f..db9eab44b9 100644 --- a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js +++ b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js @@ -101,8 +101,7 @@ window.__ModuleLoader__.load({ } }) ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () { - // The shipped rows end at Session ID (450); these follow as one group, - // opened by the export row's hairline. + // Legacy rows form their own ordered group before the built-in Archive action. yield registerSessionAction('fixture.export-session', 500, 'exportSession', true) yield registerSessionAction('fixture.copy-session-id', 600, 'copySessionId', false) }) diff --git a/apps/web/tests/hover-input.e2e.ts b/apps/web/tests/hover-input.e2e.ts index ccba69a0c7..8ec5679df0 100644 --- a/apps/web/tests/hover-input.e2e.ts +++ b/apps/web/tests/hover-input.e2e.ts @@ -103,7 +103,7 @@ it.each([false, true])('keeps workspace and archived search actions reachable wi await activate(actions) await archive.waitFor() } - for (const key of ['ArrowDown', 'Tab']) { + for (const key of ['ArrowDown', 'Home']) { await page.keyboard.press(key) expect(await page.locator('html').getAttribute('data-input-modality')).toBe('keyboard') expect(await page.getByRole('menu').evaluate(menu => menu.contains(document.activeElement))).toBe(true) diff --git a/apps/web/tests/markdown-image-refresh.e2e.ts b/apps/web/tests/markdown-image-refresh.e2e.ts index bade81cd1e..2a9dc421a3 100644 --- a/apps/web/tests/markdown-image-refresh.e2e.ts +++ b/apps/web/tests/markdown-image-refresh.e2e.ts @@ -1,9 +1,10 @@ // A rewritten local image file shows its current bytes in a newly rendered Markdown image. -import { writeFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { createMessage, 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' @@ -50,7 +51,11 @@ describe('web e2e: rewritten Markdown image files', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({}) + 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, 'chart.svg'), chart(120, 'red')) await seedSession(scaffold, chartFixture('First chart'), 'markdown-image-refresh-first', undefined, { createdAt: WEB_FIXTURE_TIME - 120_000 }) @@ -81,6 +86,14 @@ describe('web e2e: rewritten Markdown image files', () => { await page.getByRole('treeitem').nth(1).click() await page.getByText('Second chart', { exact: true }).last().waitFor() await expect.poll(width, { timeout: 10_000 }).toBe(240) + await page.getByRole('img', { name: ALT }).click({ button: 'right' }) + const action = page.getByRole('menuitem', { name: 'Download', exact: true }) + await action.waitFor() + const [download] = await Promise.all([page.waitForEvent('download'), action.click()]) + expect(download.suggestedFilename()).toBe('chart.svg') + const path = await download.path() + if (path === null) throw new Error('Browser download unavailable') + expect(await readFile(path, 'utf8')).toBe(chart(240, 'blue')) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) diff --git a/apps/web/tests/menu-material.e2e.ts b/apps/web/tests/menu-material.e2e.ts index 9f96ba008a..4f65a889a8 100644 --- a/apps/web/tests/menu-material.e2e.ts +++ b/apps/web/tests/menu-material.e2e.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { chromium, type Locator } from 'playwright' +import sharp from 'sharp' import { expect, it, onTestFinished } from 'vitest' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, webSnapshotMode } from './scaffold.ts' @@ -48,13 +49,13 @@ it('shares menu transparency and blur across palettes and follows native menu bo }) expect(overlayFill).toBe(platform === 'darwin' ? dark ? 'rgba(48, 49, 54, 0.94)' : 'rgba(248, 249, 250, 0.94)' - : dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)') + : dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)') await account.click() const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Settings', exact: true }) }) await menu.waitFor() const appearance = await material(menu) expect(appearance).toMatchObject({ - fill: dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)', + fill: dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)', blur: 'blur(40px) saturate(1.5)', }) expect(appearance.shadow).toContain(`${dark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.04)'} 0px 0px 0px 0.5px`) @@ -109,4 +110,57 @@ it('shares menu transparency and blur across palettes and follows native menu bo await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode()) await page.keyboard.press('Escape') await expect.poll(() => page.locator('[data-menu-backing]').count()).toBe(0) + + await action.locator('xpath=ancestor::*[@role="treeitem"][1]').click({ button: 'right' }) + await sessionMenu.waitFor() + const popup = page.locator('[data-context-menu]') + const stripes = await page.evaluateHandle(() => { + const element = document.createElement('div') + element.style.cssText = 'position:fixed;inset:0;z-index:1099;pointer-events:none;background:repeating-linear-gradient(90deg,#000 0 4px,#fff 4px 8px)' + document.body.appendChild(element) + return element + }) + try { + // Hide text while preserving layout so pixel contrast measures only the material. + await popup.evaluate((node) => { + for (const child of node.children) { + if (child instanceof HTMLElement && child.getAttribute('aria-hidden') !== 'true') child.style.visibility = 'hidden' + } + }) + const box = (await popup.boundingBox())! + const clip = { x: Math.floor(box.x + box.width / 2 - 32), y: Math.floor(box.y + box.height / 2 - 16), width: 64, height: 32 } + for (const phase of ['enter', 'dismiss', 'replace']) { + await popup.evaluate((node, phase) => { + node.removeAttribute('data-menu-phase') + void (node as HTMLElement).offsetWidth + node.setAttribute('data-menu-phase', phase) + for (const animation of node.getAnimations({ subtree: true })) { + animation.pause() + animation.currentTime = Number(animation.effect!.getComputedTiming().duration) / 2 + } + }, phase) + const stats = await sharp(await page.screenshot({ clip })).stats() + // During a fade the unpainted fraction exposes stripes; a working blur still + // removes substantially more contrast than opacity without backdrop sampling. + expect(stats.channels[0]!.stdev, phase).toBeLessThan(90) + } + await popup.evaluate((node) => { + node.setAttribute('data-menu-phase', 'enter') + for (const animation of node.getAnimations({ subtree: true })) animation.finish() + }) + const settled = await sharp(await page.screenshot({ clip })).stats() + expect(settled.channels[0]!.stdev).toBeLessThan(3) + await popup.evaluate((node) => { + node.style.opacity = '0.999' + // The negative control measures backdrop isolation independently of the theme's fill alpha. + const material = node.querySelector(':scope > [aria-hidden="true"]')! + material.style.backgroundColor = 'transparent' + }) + const isolated = await sharp(await page.screenshot({ clip })).stats() + expect(isolated.channels[0]!.stdev).toBeGreaterThan(90) + } finally { + await stripes.evaluate((node) => { node.remove() }) + await stripes.dispose() + await page.keyboard.press('Escape') + } }) diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts index 2036dad448..4676aba76e 100644 --- a/apps/web/tests/shortcuts-panels.e2e.ts +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -254,12 +254,12 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await menuTab.click({ button: 'right' }) await page.getByRole('menu').waitFor() expect(await page.getByRole('tooltip').count()).toBe(0) - expect(await page.getByRole('menuitem').getAttribute('aria-keyshortcuts')).toBeNull() + expect(await page.getByRole('menuitem', { name: 'Close', exact: true }).getAttribute('aria-keyshortcuts')).toBe(aria) if (attempt === 0) { await compareOrRefreshGolden(join(expected, 'tab-menu.expected.md'), - await captureStableAria(page, '[data-dockkit-tab-menu]', scaffold.workspaceCwd), mode) + await captureStableAria(page, '[data-context-menu][role="menu"]', scaffold.workspaceCwd), mode) } else { - await page.getByRole('menuitem').focus() + await page.getByRole('menuitem', { name: 'Close', exact: true }).focus() } await page.keyboard.press('Escape') await page.getByRole('menu').waitFor({ state: 'detached' }) diff --git a/apps/web/tests/sidebar-alignment.e2e.ts b/apps/web/tests/sidebar-alignment.e2e.ts index 6ea3f3dedb..82afdd29f2 100644 --- a/apps/web/tests/sidebar-alignment.e2e.ts +++ b/apps/web/tests/sidebar-alignment.e2e.ts @@ -553,6 +553,11 @@ describe('web e2e: sidebar search alignment and row action targets', () => { expect(tooltipBox.x + tooltipBox.width).toBe(anchorBox.x + anchorBox.width) await page.mouse.move(1000, 400) await tooltip.waitFor({ state: 'detached' }) + if (kind === 'session') { + expect(await menuButton.evaluate(element => element === document.activeElement)).toBe(true) + expect(await tooltipAnchor.isVisible()).toBe(true) + } + await page.mouse.click(1000, 400) expect(await tooltipAnchor.isVisible()).toBe(false) expect(await page.getByRole('tooltip').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 307e1adf12..4e5be429ce 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -1128,8 +1128,28 @@ describe('web e2e: shipped right Sidebar', () => { expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1) await column.locator('[data-dockkit-tab]').first().click({ button: 'right' }) - expect(await page.locator('[data-dockkit-tab-menu]').isVisible()).toBe(false) + // Wait for prepared content, rather than passing before the opening delay. + const emptyMenu = page.locator('[data-context-menu][role="menu"]') + await emptyMenu.waitFor({ state: 'attached' }) + expect(await emptyMenu.evaluate(node => getComputedStyle(node).display)).toBe('none') + expect(await emptyMenu.isVisible()).toBe(false) expect(await page.getByRole('menu').count()).toBe(0) + expect(await column.locator('[data-dockkit-tab]').first().evaluate(node => node.contains(document.activeElement))).toBe(true) + const backingSize = await page.locator('[data-menu-backing]').evaluate((node) => { + const root = document.documentElement + const platform = root.getAttribute('data-platform') + root.setAttribute('data-platform', 'darwin') + try { + const { width, height } = node.getBoundingClientRect() + return { width, height } + } finally { + if (platform === null) root.removeAttribute('data-platform') + else root.setAttribute('data-platform', platform) + } + }) + expect(backingSize).toEqual({ width: 0, height: 0 }) + await page.keyboard.press('Escape') + await emptyMenu.waitFor({ state: 'detached' }) // Any other tab standing alone closes together with the column. Open the // sample file, close the guide (an ordinary close with two tabs), then @@ -1231,13 +1251,10 @@ describe('web e2e: shipped right Sidebar', () => { // is the secondary press. expect(await column.locator('[data-dockkit-tab-more]').count()).toBe(0) await column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }).click({ button: 'right' }) - const menu = page.locator('[data-dockkit-tab-menu]') + const menu = page.locator('[data-context-menu][role="menu"]') await expect.poll(async () => await menu.count()).toBe(1) - // The kit's one item; the extension seat is declared and rendered, and - // with no registrant it contributes nothing, which is what "declared, not - // speculative" looks like from the outside. - await expect.poll(async () => await menu.getByRole('menuitem').allInnerTexts()).toEqual(['Close']) + await expect.poll(() => menu.ariaSnapshot()).toBe('- menu:\n - menuitem "Close"\n - menuitem "Close Other Tabs"') // The menu hangs below the strip that clips its overflow. Its pixels are // its own: a hit test at its centre lands on it, not on whatever the // strip would have shown through a clipped box. diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index ba6cb7cbe3..096c159be4 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -567,7 +567,10 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo const consoleWatch = watchConsole(hoverPage) await hoverPage.goto(scaffold.authenticatedUrl) const row = await seededSessionRow(hoverPage) + await row.click() + await hoverPage.getByText('DONE', { exact: true }).waitFor() const title = row.locator('[class*="title"]') + await expect.poll(() => title.innerText()).not.toBe('Untitled') const card = hoverPage.getByRole('button', { name: `Copy: ${await title.innerText()}` }) const tooltip = hoverPage.getByRole('tooltip') const observations: { stage: string; details: boolean; tooltips: string[] }[] = [] @@ -575,6 +578,7 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo observations.push({ stage, details: await card.count() > 0, tooltips: await tooltip.allTextContents() }) } const clockStart = Date.now() + await hoverPage.getByRole('button', { name: 'Account menu', exact: true }).hover() await hoverPage.clock.install({ time: clockStart }) await hoverPage.clock.pauseAt(clockStart + 1000) await title.hover() @@ -612,9 +616,11 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo const menu = row.getByRole('button', { name: /^Session actions for / }) await menu.click() + await hoverPage.clock.runFor(1000) await hoverPage.getByRole('menu').waitFor() expect(await card.count()).toBe(0) await hoverPage.keyboard.press('Escape') + await hoverPage.clock.runFor(1000) await hoverPage.getByRole('menu').waitFor({ state: 'detached' }) await title.hover() await hoverPage.clock.runFor(1600) @@ -821,8 +827,10 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo await item.hover() await page.waitForTimeout(POINTER_HOLD_MS) expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) - // Pointer-leave dismissal still applies once the pointer genuinely leaves. + // Context menus stay open across pointer departure and dismiss on an outside press. await page.getByRole('button', { name: 'Account menu', exact: true }).hover() + expect(await item.isVisible()).toBe(true) + await page.locator('[data-row-key]').first().click() await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) @@ -862,9 +870,8 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - // Initial Workspace reconnection can focus the composer after the tree renders. - // Finish that navigation before the next test opens a path editor. - await page.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 15_000 }) + // The last Session can leave the new-session page with no selected row. + // Wait for composer focus so reconnection cannot steal the next editor's focus. await expect.poll( () => page.locator('[data-composer-input][contenteditable="true"]') .evaluate(element => element === document.activeElement), diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c653301218..c30c255870 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -27,6 +27,7 @@ "tests/creation-recommendations.e2e.ts", "tests/plugin-recommendations.e2e.ts", "tests/japanese-plugin-metadata.e2e.ts", + "tests/context-menus.e2e.ts", "tests/additional-locales.e2e.ts", "tests/rich-text-quotes.e2e.ts", "tests/hover-input.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ca0dc021f1..5d725a8ce8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4832,6 +4832,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-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | @@ -4839,6 +4840,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 2b9d252a52..183f27f2bf 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4834,6 +4834,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-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | @@ -4841,6 +4842,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/event-producer-consumer.md b/docs/event-producer-consumer.md index e4318ec102..29439480a9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -104,6 +104,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | Event string | Dispatchers | Listeners | | --- | --- | --- | | `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | +| `connection/reset` | `gateway` (`emit`) | `terminal-controller`, `ui-settings` | | `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | @@ -111,7 +112,9 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | +| `locale/change` | `locale` (`emit`) | `locale` | | `slots/changed` | `ui-renderer` (`emit`) | - | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index cb38f9f44c..f1fc79d7d9 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -106,6 +106,7 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | | `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | +| `connection/reset` | `gateway` (`emit`) | `terminal-controller`, `ui-settings` | | `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | @@ -113,7 +114,9 @@ | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | +| `locale/change` | `locale` (`emit`) | `locale` | | `slots/changed` | `ui-renderer` (`emit`) | - | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 025d6ea304..433befe3a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -165,12 +165,14 @@ flowchart TD pkg_client_ui_brand_official["client-ui-brand-official"] pkg_client_ui_chat["client-ui-chat"] pkg_client_ui_commands["client-ui-commands"] + pkg_client_ui_context_menu["client-ui-context-menu"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] pkg_client_ui_directory_picker_browse["client-ui-directory-picker-browse"] pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_dockkit["client-ui-dockkit"] pkg_client_ui_entry_customization["client-ui-entry-customization"] + pkg_client_ui_file_actions["client-ui-file-actions"] pkg_client_ui_file_drag["client-ui-file-drag"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] @@ -1461,6 +1463,7 @@ flowchart TD pkg_client_test_runtime --> pkg_client_modules pkg_client_test_runtime --> pkg_client_store pkg_client_test_runtime --> pkg_client_ui_chat + pkg_client_test_runtime --> pkg_client_ui_context_menu pkg_client_test_runtime --> pkg_client_ui_conversation pkg_client_test_runtime --> pkg_client_ui_renderer pkg_client_test_runtime --> pkg_client_ui_session @@ -1545,12 +1548,14 @@ flowchart TD | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | — | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | — | +| [`client-ui-context-menu`](../packages/client/ui-context-menu) | `client` | — | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | — | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | — | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — | | [`client-ui-entry-customization`](../packages/client/ui-entry-customization) | `client` | — | +| [`client-ui-file-actions`](../packages/client/ui-file-actions) | `client` | — | | [`client-ui-file-drag`](../packages/client/ui-file-drag) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | @@ -1863,7 +1868,7 @@ flowchart TD | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 47141c6e0b..3cc4f214b7 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -167,12 +167,14 @@ flowchart TD pkg_client_ui_brand_official["client-ui-brand-official"] pkg_client_ui_chat["client-ui-chat"] pkg_client_ui_commands["client-ui-commands"] + pkg_client_ui_context_menu["client-ui-context-menu"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] pkg_client_ui_directory_picker_browse["client-ui-directory-picker-browse"] pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_dockkit["client-ui-dockkit"] pkg_client_ui_entry_customization["client-ui-entry-customization"] + pkg_client_ui_file_actions["client-ui-file-actions"] pkg_client_ui_file_drag["client-ui-file-drag"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] @@ -1463,6 +1465,7 @@ flowchart TD pkg_client_test_runtime --> pkg_client_modules pkg_client_test_runtime --> pkg_client_store pkg_client_test_runtime --> pkg_client_ui_chat + pkg_client_test_runtime --> pkg_client_ui_context_menu pkg_client_test_runtime --> pkg_client_ui_conversation pkg_client_test_runtime --> pkg_client_ui_renderer pkg_client_test_runtime --> pkg_client_ui_session @@ -1547,12 +1550,14 @@ flowchart TD | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | — | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | — | +| [`client-ui-context-menu`](../packages/client/ui-context-menu) | `client` | — | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | — | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | — | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — | | [`client-ui-entry-customization`](../packages/client/ui-entry-customization) | `client` | — | +| [`client-ui-file-actions`](../packages/client/ui-file-actions) | `client` | — | | [`client-ui-file-drag`](../packages/client/ui-file-drag) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | @@ -1865,7 +1870,7 @@ flowchart TD | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 17ae54903e..0a2bb60f40 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -9,8 +9,8 @@ en: f2d2c0d3b8f14ec7 zh: 441ed13ff3f81d2a /web-client-slots/cardinality-and-scope: - en: 5b0ada069239576b - zh: 84e1e6b253d1461e + en: cd17e3e0ff6e62ff + zh: 6e0db2cb16c41b73 /web-client-slots/component-inputs: en: 9cb70abb2e39b958 zh: cd4b6f1705d90e17 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index d64184dbc0..fe8589f685 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -65,6 +65,8 @@ The slot declaration fixes two independent axes. `priority` is a shadowing rank for `single`, `list`, and `keyed` cells and an election order for `chain`. Lower values run or render first. Ordinary additive contributions should choose a fresh list `id` or keyed `key`; intentionally reusing a shipped cell replaces its presentation. +The [context-menu plugin](../../packages/client/ui-context-menu/README.md) contributes `useContextMenu` through `provideRoot`; every scope receives the same plugin-owned trigger factory. Its prop and retained handlers retire when the provider unloads. Its root-scoped keyed `contextMenu.icon` Slot renders business-owned icons from action metadata. + ## Component inputs A registered component receives inputs assembled at its binding site. Components derive these types rather than copying their members. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index db7509aefe..288b4b85f1 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -67,6 +67,8 @@ Slot 声明固定两个相互独立的维度。 +[右键菜单插件](../../packages/client/ui-context-menu/README.zh.md) 通过 `provideRoot` 贡献 `useContextMenu`;每个作用域接收同一个由插件拥有的触发器工厂。提供方卸载时,注入属性与保留的处理器一并失效。其根作用域 keyed Slot `contextMenu.icon` 根据操作元数据渲染业务方提供的图标。 + ## 组件输入 注册组件会在 binding 位置收到组装后的输入。组件应从这些类型推导 props,不要重新抄写成员。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml new file mode 100644 index 0000000000..8249edf3c4 --- /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: 3eaef415166d076f + zh: 4040390d2572fc47 +/context-menu-services-in-custom-client-compositions/migration: + en: ebffe957534ac31b + zh: a33d2782f5ad0f5d 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..082e1da33f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md @@ -0,0 +1,23 @@ +--- +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 `contextMenus` to open shared object menus. The open-in-app plugin also injects `fileActions`, `sidebarRight`, `sidebarRightTabs`, and `remote.workspaceFiles`. Custom compositions that mount these GUI plugins without the new services cannot activate them. The shipped Web application bundle includes the providers; existing Session and tab menu Slot names remain available. + +## Migration + +1. In the package manifest that owns a custom composition, add dependencies on `@deepseek-ai/dsh-client-ui-context-menu` and `@deepseek-ai/dsh-client-ui-file-actions` at the same Harness version. +2. Add both package names as plugin rows in the custom Client composition alongside the existing locale, slots, shortcuts, sidebar, and workspace-file providers. The default `@deepseek-ai/dsh-web-app` bundle already contains these rows. +3. Rename `ctx.menus` to `ctx.contextMenus` and `MenuTargetMap` to `ContextMenuTargetMap`; neither old name has an alias. Mount the menu plugin so its `ctx.slots.provideRoot` contribution supplies `useContextMenu` to every Slot. Components call the hook once and spread `getTriggerProps(target, handlers?)` onto each element, including elements created in loops. See the [menu reference](../../../../packages/client/ui-context-menu/README.md) for handler composition and external action registration. +4. Rebuild and launch the custom profile. Confirm that GUI plugins activate, right-clicking a background Session preserves selection, and existing Session/tab Slot contributions still appear. +5. Custom Slot hosts must remove their declarations of `sidebar.workspaces.session.menu.item` and `sidebar.right.tab.menu.item`; the workspace and right-sidebar adapters now own these under `contextMenu.content`. Session rows retain the `[open, setOpen]` hook context, and tab rows receive `{ tab, dismiss }` within the clicked Session’s provider. A custom host replacing these adapters must declare the same children and supply those owners itself. Dockkit embedders using `getTabTriggerProps` own the popup and dismissal; `renderTabMenuItems` is used only by the kit popup. Supply reactive `tabMenuOpen` to suppress close-button tooltips while a custom menu prepares or remains open. + +6. Built-in Session menu items are data contributions. The legacy React Slot retains external entries together before Archive; Slot priority no longer shadows built-in actions. Use `ctx.contextMenus.register` for new actions and optional `commandId` to display an existing command’s current binding. Keep captured-target execution in `run`; no command dispatch is added. +7. Menu contributions use `icon?: ReactNode`; replace `symbol` with a glyph element and wrap icon URLs in ``. Obtain `useContextMenu` at each actual Slot entry. Plain children receive `getTriggerProps` or bound `contextMenuProps`; ToolRow uses `fileContextMenuProps` and `urlContextMenuProps`. Remove menu fields from Chat flow/node owners. The assistant entry supplies an ordinary file adapter to `forClosing()`, while Markdown adapts its target type at `MarkdownDelegateProvider.getTriggerProps`. Keep Session identity and gesture-time path resolution in the business registration. Remove imports of `contextMenuTrigger` and `menuPoint`; their independent utility package is removed. Do not add gesture exports to ui-primitives. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md new file mode 100644 index 0000000000..9281f9bee1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "自定义 Client 组合必须提供 GUI 插件所需的右键菜单与文件动作服务。" +--- + +# 自定义 Client 组合中的右键菜单服务 + +[English](guide.md) | 中文 + +## 变更 + +GUI 插件现在注入 `contextMenus` 以打开共享对象菜单。open-in-app 插件还注入 `fileActions`、`sidebarRight`、`sidebarRightTabs` 和 `remote.workspaceFiles`。自定义组合若挂载这些 GUI 插件却没有新服务,将无法激活它们。随附 Web 应用 bundle(插件包)已包含提供方;现有 Session 和标签页菜单 Slot 名称仍可使用。 + +## 迁移 + +1. 在自定义组合所属的包清单中,添加与 Harness 版本一致的 `@deepseek-ai/dsh-client-ui-context-menu` 和 `@deepseek-ai/dsh-client-ui-file-actions` 依赖。 +2. 在自定义 Client 组合中将两个包名添加为插件行,与已有 locale、slots、shortcuts、侧栏和工作区文件提供方并列。默认 `@deepseek-ai/dsh-web-app` bundle 已包含这些行。 +3. 将 `ctx.menus` 改为 `ctx.contextMenus`,将 `MenuTargetMap` 改为 `ContextMenuTargetMap`,旧名均无兼容别名。挂载菜单插件,由其 `ctx.slots.provideRoot` 贡献向每个 Slot 提供 `useContextMenu`。组件调用一次 hook,将 `getTriggerProps(target, handlers?)` 展开到每个元素,包括循环生成的元素。处理器组合和外部动作注册见[菜单参考](../../../../packages/client/ui-context-menu/README.zh.md)。 +4. 重新构建并启动自定义 profile(配置档)。确认 GUI 插件正常激活、右键后台 Session 不改变选择,且现有 Session/标签页 Slot 贡献仍然显示。 +5. 自定义 Slot 宿主应移除对 `sidebar.workspaces.session.menu.item` 和 `sidebar.right.tab.menu.item` 的声明;现在由工作区和右侧栏适配器在 `contextMenu.content` 下拥有它们。会话行继续接收 `[open, setOpen]` hook context,标签行在所点击会话的 provider 内接收 `{ tab, dismiss }`。替换这些适配器的自定义宿主须自行声明相同的子 Slot 并提供这些 owner。使用 `getTabTriggerProps` 的 Dockkit 嵌入方负责弹窗和关闭,`renderTabMenuItems` 仅供组件自身的弹窗使用。通过响应式 `tabMenuOpen` 在自定义菜单准备或显示时禁用关闭按钮的 Tooltip。 + +6. 内置 Session 菜单项改为数据贡献。旧 React Slot 将外部条目集中显示在归档之前,Slot priority 不再覆盖内置动作。新增动作用 `ctx.contextMenus.register`,可通过 `commandId` 展示已有命令的当前绑定。针对捕获目标的执行仍放在 `run`,不增加命令调度。 +7. 菜单贡献使用 `icon?: ReactNode`:将 `symbol` 改为图标元素,并用 `` 包装图标 URL。在每个实际 Slot 入口获取 `useContextMenu`。普通子组件接收 `getTriggerProps` 或绑定后的 `contextMenuProps`;ToolRow 使用 `fileContextMenuProps` 和 `urlContextMenuProps`。删除 Chat 消息流和节点 owner 的菜单字段。助手入口向 `forClosing()` 传入普通文件适配函数,Markdown 则在 `MarkdownDelegateProvider.getTriggerProps` 处适配目标类型。业务注册保留 Session 归属和触发时路径解析。删除 `contextMenuTrigger` 和 `menuPoint` 导入,其独立工具包已删除。不要向 ui-primitives 增加手势导出。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.i18n.yaml new file mode 100644 index 0000000000..881f6d61d6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/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.3/web-file-downloads/guide.md +/: + en: 25674d6a93c97ca5 + zh: ac1563eccab1b284 +/file-downloads-on-web: + en: d8fac1a37dede04d + zh: 8f132360f0553b64 +/file-downloads-on-web/change: + en: d76c6132a5e5fc9d + zh: 691b2264a37a526f +/file-downloads-on-web/migration: + en: a4365e73ae4e2de4 + zh: 8988bc1703fc5b8d diff --git a/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.md new file mode 100644 index 0000000000..dcc26609ac --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Custom file-action menus must offer save-as only on Desktop and browser downloads on Web." +--- + +# File downloads on Web + +English | [中文](guide.zh.md) + +## Change + +The Web file-action service no longer offers save-as through a browser file picker. `canSaveAs` is always false on Web, and `save(source, 'saveAs')` rejects before reading the source. Built-in Web file and image menus offer Download and omit Save as. Desktop still offers both actions; successful saves return the absolute destination `path` instead of an opaque file ID. + +## Migration + +1. Custom Client menus should include Save as only when `ctx.fileActions.canSaveAs` is true. +2. On Web, call `ctx.fileActions.save(source, 'download')`. The browser handles the original content and suggested filename; a `requested` result means download started, not completed. Do not report a saved local path. +3. Replace `savedFile(url)` and `reveal(id)` calls with an action on the completed `saved` result, using its `path` with `session.openWorkspacePath`. Keep original-file context-menu reveal independent of saved destinations. +4. Verify that Web file and image menus expose Download without Save as, including browsers that support `showSaveFilePicker`. Verify that Desktop image menus retain both actions. See the [file-action reference](../../../../packages/client/ui-file-actions/README.md) for save results and feedback ownership. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.zh.md new file mode 100644 index 0000000000..d9e232845a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "自定义文件动作菜单应仅在 Desktop 提供另存为,在 Web 使用浏览器下载。" +--- + +# Web 文件下载 + +[English](guide.md) | 中文 + +## 变更 + +Web 文件动作服务不再通过浏览器文件选择器提供另存为。Web 的 `canSaveAs` 始终为 false,`save(source, 'saveAs')` 会在读取来源前拒绝。内置 Web 文件和图片菜单提供「下载」,不显示「另存为」。Desktop 仍提供两种操作;保存成功后返回目标绝对路径 `path`,不再返回不透明文件 ID。 + +## 迁移 + +1. 自定义 Client 菜单仅在 `ctx.fileActions.canSaveAs` 为 true 时添加「另存为」。 +2. Web 端调用 `ctx.fileActions.save(source, 'download')`。浏览器处理原始内容和建议文件名;`requested` 结果仅表示下载已开始,不代表完成。不要报告本机保存路径。 +3. 将 `savedFile(url)` 和 `reveal(id)` 调用替换为完成后 `saved` 结果上的操作,使用其中的 `path` 调用 `session.openWorkspacePath`。原文件右键定位应独立于保存目标。 +4. 验证 Web 文件和图片菜单只有「下载」而没有「另存为」,包括支持 `showSaveFilePicker` 的浏览器;验证 Desktop 图片菜单保留两种操作。保存结果与提示的归属参见[文件动作参考](../../../../packages/client/ui-file-actions/README.zh.md)。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.i18n.yaml new file mode 100644 index 0000000000..74990defdf --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/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.2-alpha.2/context-menu-target/guide.md +/: + en: 6c606440532dfae0 + zh: 9308d72d451e12c7 +/context-menu-types-and-icons: + en: d1237d3842936881 + zh: b32fcacf82bed5f1 +/context-menu-types-and-icons/change: + en: 35faa7bd1cff539f + zh: 0dc2515e0025e3d8 +/context-menu-types-and-icons/migration: + en: e15e76d8d6a95f35 + zh: 4bf2dec8d764e5ae diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.md new file mode 100644 index 0000000000..0f33207f01 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Client plugins must use the prefixed context menu types and hooks and register keyed icon components." +--- + +# Context menu types and icons + +English | [中文](guide.zh.md) + +## Change + +The `@deepseek-ai/dsh-client-ui-context-menu/client` export `MenuTarget` is renamed to `ContextMenuTarget`, matching `ContextMenuTargetMap`. The old name has no compatibility alias. Target fields and runtime behavior are unchanged. Workspace and sidebar injections rename `menuTarget` / `useMenuTarget` to `contextMenuTarget` / `useContextMenuTarget`. Action icons accept `{ key, src? }` instead of React nodes and render through the keyed `contextMenu.icon` Slot. + +## Migration + +1. Replace `MenuTarget` imports and type references with `ContextMenuTarget`. +2. Rename injected `menuTarget` sources and `useMenuTarget` consumers to `contextMenuTarget` and `useContextMenuTarget`. +3. Replace action icon elements with `{ key: 'my-plugin.action' }`; carry a captured image URL in `src` when needed. Register the icon component under that key through `ctx.slots.inject('contextMenu.icon', ...)` and `ctx.slots.register()`. See the [menu package](../../../../packages/client/ui-context-menu/README.md#use-this-package). +4. Rebuild custom Client plugins, confirm that type checks pass, and open their menus to verify icon rendering and plugin-unload cleanup. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.zh.md new file mode 100644 index 0000000000..e6bcb0cd2c --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Client 插件须使用带前缀的右键菜单类型和 hook,并注册 keyed 图标组件。" +--- + +# 右键菜单类型和图标 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-client-ui-context-menu/client` 导出的 `MenuTarget` 更名为 `ContextMenuTarget`,与 `ContextMenuTargetMap` 保持一致。旧名不保留兼容别名,目标字段和运行时行为不变。工作区与侧栏注入的 `menuTarget` / `useMenuTarget` 改为 `contextMenuTarget` / `useContextMenuTarget`。操作图标改用 `{ key, src? }`,通过 keyed Slot `contextMenu.icon` 渲染,不再接收 React 节点。 + +## 迁移 + +1. 将 `MenuTarget` 导入及类型引用替换为 `ContextMenuTarget`。 +2. 将注入源 `menuTarget` 和消费者 `useMenuTarget` 分别改为 `contextMenuTarget`、`useContextMenuTarget`。 +3. 将操作图标元素替换为 `{ key: 'my-plugin.action' }`,需要时通过 `src` 携带已捕获的图片 URL。通过 `ctx.slots.inject('contextMenu.icon', ...)` 和 `ctx.slots.register()` 在该 key 下注册图标组件,参见[菜单包](../../../../packages/client/ui-context-menu/README.zh.md#use-this-package)。 +4. 重新构建自定义 Client 插件,确认类型检查通过,并打开菜单验证图标渲染和插件卸载后的清理。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c0ae7077d9..53d2b2971e 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 3a94c4aeda..9ecc295724 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -191,7 +191,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/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index ecf23dd6e9..d94b2a195b 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -45,6 +46,7 @@ function mount(active = 'en') { usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), ...allowAllAccess, + useContextMenu: contextMenuHook(), useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => key === 'language.title' ? 'Language' : key, diff --git a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx index 578234c898..83ea649c80 100644 --- a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { useState, type ComponentProps } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -30,6 +31,7 @@ function view(roster: Partial = {}, inMenu = false) { usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, ...allowAllAccess, + useContextMenu: contextMenuHook(), load, startCreatorDraft, onDismiss, } function TestMenu() { diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 1ea42a6af1..59f2bf5842 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' @@ -24,6 +25,7 @@ function view(partial: Partial = {}, startCreatorDraft? usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, ...allowAllAccess, + useContextMenu: contextMenuHook(), useAgentPresetSection: bindSnapshotSelector(store), useDeveloperTools: bindSnapshotSelector(developerTools), t: key => translations.get(key) ?? key } diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 65b551434d..fe9e9182e5 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-attachment/use-this-package: - en: df47b6abd789dcb5 - zh: 7e17c7f18631a572 + en: 02870b565a595b51 + zh: c31d02d76d78094b /deepseek-ai-dsh-client-ui-attachment/use-this-package/draft-attachments: en: 8246cb3c470e3fc5 zh: 7028cf466a832b99 @@ -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..7b3158b50b 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -25,7 +25,7 @@ This package renders everything the conversation UI shows about attachments: one ## Use this package -Image drag actions and the composer’s original-file reader arrive through slot injection from [ui-file-drag](../ui-file-drag/README.md). The image gallery Factory also receives these actions through its declared inject face; custom Factory replacements must supply that face. +Image drag actions and the composer’s original-file reader arrive through slot injection from [ui-file-drag](../ui-file-drag/README.md). The image gallery Factory also receives these actions through its declared inject face; custom Factory replacements must supply that face. `MessageImage` and `ImageGallery` require the ordinary `getTriggerProps` factory; they bind each original image once and pass the same `contextMenuProps` to its thumbnail and lightbox. Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) and [`ui-tool`](../ui-tool/README.md) when tool results need an image gallery. It waits for their slot declarations and registers its components into them. Users then see the mixed draft-attachment rail, DeepSeek Web file cards with upload controls, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox. @@ -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..7391553fe2 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -图片拖放回调与输入框的原图读取回调通过 slot 注入从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。图片图库 Factory 也通过声明的注入接口接收回调;自定义 Factory 替换实现必须提供该接口。 +图片拖放回调与输入框的原图读取回调通过 slot 注入从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。图片图库 Factory 也通过声明的注入接口接收回调;自定义 Factory 替换实现必须提供该接口。 `MessageImage` 和 `ImageGallery` 必须接收普通 `getTriggerProps` 函数,每张原图绑定一次,将同一份 `contextMenuProps` 传给缩略图和灯箱。 与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件,工具结果需要图片图库时也要挂载 [`ui-tool`](../ui-tool/README.zh.md)。插件等待这些 slot 的声明,并把组件注册进去。用户会看到混合草稿附件栏、带上传控件的 DeepSeek Web 文件卡、带限制说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片图库,以及支持 Escape、遮罩和关闭按钮的灯箱。 @@ -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 ed6496c2cf..2f5d2d5d93 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 5adcfb69a3..7afdb8b245 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,3 +1,4 @@ +import type { ContextMenuBindings } 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, getTriggerProps }: { image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' | 'thumbnail' fileDrag: FileDragActions labels: MessageImageLabels + getTriggerProps: ContextMenuBindings['getTriggerProps'] }) { const preview = 'preview' in image ? image.preview : undefined const attachment = 'attachment' in image ? image.attachment : undefined @@ -110,6 +112,9 @@ export function MessageImage({ image, load, variant, labels, fileDrag }: { const src = preview?.url ?? loaded const label = ('attachment' in image ? image.label : undefined) ?? preview?.name ?? attachment?.name ?? labels.image + const contextMenuProps = getTriggerProps(src === null ? undefined : { kind: 'image', value: { + url: src, name: label, ...(attachment === undefined ? {} : { mediaType: attachment.mediaType }), + } }) const loadingThumbnail = variant === 'thumbnail' && src === null if (error) return ( - {open && src !== null && } + {open && src !== null && } ) } /** 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, getTriggerProps }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' @@ -161,6 +168,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail = thumbnail?: boolean fileDrag: FileDragActions labels: MessageImageLabels + getTriggerProps: ContextMenuBindings['getTriggerProps'] }) { if (images.length === 0) return null const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single' @@ -174,6 +182,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail = variant={variant} fileDrag={fileDrag} labels={labels} + getTriggerProps={getTriggerProps} /> ))} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 0a95ddc66d..176940777f 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -12,11 +12,12 @@ type MessageImagesProps = MessageImagesOwnerProps & PropsRenderFactories * @param props - image sources, authorized loader, layout hints, optional labels, and Factory locale seat. * @returns the official gallery; no Session binding is needed by this component. */ -export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag }: +export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag, useContextMenu }: FactoryComponentPropsOf<'conversation.images.content'>) { + const { getTriggerProps } = useContextMenu() const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t]) return + compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} getTriggerProps={getTriggerProps} /> } /** diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 7ef44e1dd8..ef0f496a78 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -1,5 +1,7 @@ +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 { SlotFactoryMap } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -9,6 +11,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-file-drag/client' import { ComposerAttachments, type ComposerAttachmentsInjected } from './ComposerAttachments.tsx' import { MessageImages, MessageImagesContent } from './MessageImages.tsx' +type MessageImagesInjected = SlotFactoryMap['conversation.images.content']['inject'] + /** Slot registry required by the attachment presentation plugin. */ export const inject = ['slots', 'fileDrag'] @@ -19,7 +23,9 @@ 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: (): MessageImagesInjected => ({ + fileDrag: ctx.fileDrag.actions, + }), }, 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..98f3d923a3 100644 --- a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx +++ b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom /** Independent domain image positions with one shared official gallery Factory. */ import * as fileDragPlugin from '@deepseek-ai/dsh-client-ui-file-drag/client' +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { act, cleanup, within } from '@testing-library/react' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' @@ -29,6 +30,10 @@ async function bench() { runtime.slots.installLocale(locale) new UiConversation(runtime.ctx, runtime.sessions) await runtime.mount(fileDragPlugin) + runtime.ctx.provide('contextMenus', { open: vi.fn() } as never) + runtime.slots.provideRoot({ props: { + useContextMenu: contextMenuHook((target, point) => { runtime.ctx.contextMenus.open(target, point) }), + } }) await runtime.mount(attachmentPlugin) await runtime.sessions.add({ id: 'image-domains' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-domains')) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index dd6588f8bf..68cf23a7c8 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { useState } from 'react' import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -39,7 +40,8 @@ describe('MessageImage', () => { const load = Object.assign(vi.fn(() => new Promise(() => {})), { peek: vi.fn(() => 'blob:seeded'), }) - const view = render() + const view = render() expect(view.queryByText('图片加载中…')).toBeNull() expect((view.getByAltText('history.png') as HTMLImageElement).src).toContain('blob:seeded') expect(load).toHaveBeenCalledWith(attachment) @@ -47,7 +49,8 @@ describe('MessageImage', () => { it('loads a Gateway-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -62,7 +65,8 @@ describe('MessageImage', () => { it('ignores a click while the thumbnail is still loading', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(view.getByText('图片加载中…')).toBeTruthy() fireEvent.click(frame) @@ -72,7 +76,8 @@ describe('MessageImage', () => { it('falls back to the image label for an unnamed attachment', async () => { const { name: _named, ...unnamed } = attachment const load = vi.fn().mockResolvedValue('blob:unnamed') - const view = render() + const view = render() await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() }) @@ -82,7 +87,8 @@ describe('MessageImage', () => { .mockRejectedValueOnce(new Error('offline')) .mockRejectedValueOnce(new Error('still offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) @@ -94,7 +100,8 @@ describe('MessageImage', () => { it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => { const load = vi.fn().mockResolvedValue('blob:ratio') const tall = render( - , + , ) const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' }) expect(tallFrame.getAttribute('style')).toContain('width: 60px') @@ -103,7 +110,8 @@ describe('MessageImage', () => { expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top') tall.unmount() const wide = render( - , + , ) const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' }) expect(wideFrame.getAttribute('style')).toContain('width: 240px') @@ -112,7 +120,8 @@ describe('MessageImage', () => { expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center') wide.unmount() const small = render( - , + , ) const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' }) expect(smallFrame.getAttribute('style')).toContain('width: 100px') @@ -121,7 +130,8 @@ describe('MessageImage', () => { it('renders a tile at the fixed square without inline sizing', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('data-variant')).toBe('tile') expect(frame.getAttribute('style')).toBeNull() @@ -129,7 +139,9 @@ describe('MessageImage', () => { it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) expect(retry.getAttribute('data-variant')).toBe(variant) }) @@ -140,7 +152,8 @@ describe('MessageImage', () => { ] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => { const pending = Promise.withResolvers() const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry') - const view = render() + const view = render() const loading = view.getByRole('button', { name: statusLabels.loading }) expect(loading.getAttribute('title')).toBe(statusLabels.loading) expect(loading.getAttribute('aria-busy')).toBe('true') @@ -164,7 +177,8 @@ describe('MessageImage', () => { it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => { const { name: _name, ...unnamed } = attachment const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail') - const view = render() + const view = render() fireEvent.click(await view.findByRole('button', { name: labels.loadFailed })) const image = await view.findByAltText('Image 2') expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail') @@ -182,13 +196,15 @@ describe('MessageImage', () => { it('ignores a load settling after unmount', async () => { let resolve: ((url: string) => void) | undefined const load = vi.fn(() => new Promise((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() second.unmount() reject?.(new Error('late failure')) await Promise.resolve() @@ -199,7 +215,7 @@ describe('MessageImage preview arm', () => { it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => { const load = vi.fn() const view = render( - { it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => { const load = vi.fn() const view = render( - , + , ) const img = view.getByAltText('图片') as HTMLImageElement const frame = img.closest('button') as HTMLButtonElement @@ -227,7 +244,8 @@ describe('MessageImage preview arm', () => { it('opens the lightbox from a preview thumbnail', () => { const view = render( - '')} variant="tile" labels={labels} />, + '')} variant="tile" labels={labels} />, ) fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' })) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() @@ -237,10 +255,11 @@ describe('MessageImage preview arm', () => { describe('ImageGallery', () => { it('renders nothing without images and an aligned wrapping group with them', async () => { const load = vi.fn().mockResolvedValue('blob:gallery') - const empty = render() + const empty = render() expect(empty.container.firstChild).toBeNull() const view = render( - { it('renders a lone image large and compact or grouped images as square tiles', () => { const load = vi.fn(() => new Promise(() => {})) - const lone = render() + const lone = render() expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1) lone.unmount() const compact = render( - , + , ) expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1) compact.unmount() const several = render( - , + , ) expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3) }) @@ -277,6 +299,10 @@ describe('ImageGallery', () => { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) + runtime.ctx.provide('contextMenus', { open: vi.fn() } as never) + runtime.slots.provideRoot({ props: { + useContextMenu: contextMenuHook((target, point) => { runtime.ctx.contextMenus.open(target, point) }), + } }) await runtime.mount(attachmentPlugin) const images = [{ preview: { url: 'blob:labels', name: 'preview.png' } }] const loadImage = vi.fn(async () => 'blob:unused') @@ -291,10 +317,14 @@ describe('ImageGallery', () => { }) const view = runtime.renderRoot() const image = view.getByAltText('preview.png') + fireEvent.contextMenu(image, { clientX: 7, clientY: 8 }) + expect(vi.spyOn(runtime.ctx.contextMenus, 'open')).toHaveBeenCalledWith({ kind: 'image', value: { url: 'blob:labels', name: 'preview.png' } }, { x: 7, y: 8 }) const frame = view.getByRole('button', { name: 'preview.png,点击查看原图' }) expect(frame.getAttribute('title')).toBe(labels.open) fireEvent.click(frame) expect(view.getByRole('dialog', { name: labels.lightbox.dialog })).toBeTruthy() + fireEvent.contextMenu(view.getByRole('dialog').querySelector('img')!, { clientX: 13, clientY: 14 }) + expect(vi.spyOn(runtime.ctx.contextMenus, 'open')).toHaveBeenLastCalledWith({ kind: 'image', value: { url: 'blob:labels', name: 'preview.png' } }, { x: 13, y: 14 }) fireEvent.click(view.getByRole('button', { name: labels.lightbox.close })) fireEvent.click(view.getByRole('button', { name: 'Default labels' })) const defaultFrame = view.getByRole('button', { name: 'preview.png, click to view original' }) @@ -318,6 +348,10 @@ describe('ImageGallery', () => { runtime.slots.installLocale(locale) await runtime.declare({ 'conversation.chat.message.images': { kind: 'single', scope: 'session' } }) runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) + runtime.ctx.provide('contextMenus', { open: vi.fn() } as never) + runtime.slots.provideRoot({ props: { + useContextMenu: contextMenuHook((target, point) => { runtime.ctx.contextMenus.open(target, point) }), + } }) await runtime.mount(attachmentPlugin) await runtime.sessions.add({ id: 'message-images-test' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test')) @@ -330,6 +364,8 @@ describe('ImageGallery', () => { expect(view.getByRole('button', { name: 'history.png,点击查看原图' })).toBeTruthy() expect(container.querySelector('[data-align="end"]')).not.toBeNull() expect(loadImage).toHaveBeenCalledWith(attachment) + fireEvent.contextMenu(view.getByAltText('history.png'), { clientX: 9, clientY: 10 }) + expect(vi.spyOn(runtime.ctx.contextMenus, 'open')).toHaveBeenCalledWith({ kind: 'image', value: { url: 'blob:slot-image', name: 'history.png', mediaType: 'image/png' } }, { x: 9, y: 10 }) expect(view.getByRole('button', { name: 'history.png,点击查看原图' }).getAttribute('data-variant')).toBe('single') for (const [compact, thumbnail, variant] of [ [true, false, 'tile'], @@ -346,3 +382,16 @@ describe('ImageGallery', () => { }) const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } + +it('opens image menus from the thumbnail and lightbox using the original source', async () => { + const openImageMenu = vi.fn() + const view = render( 'blob:original')} + variant="single" labels={labels} />) + const image = await view.findByAltText('history.png') + fireEvent.contextMenu(image, { clientX: 18, clientY: 29 }) + expect(openImageMenu).toHaveBeenLastCalledWith({ kind: 'image', value: { url: 'blob:original', name: 'history.png', mediaType: attachment.mediaType } }, { x: 18, y: 29 }) + fireEvent.click(image) + fireEvent.contextMenu(view.getByRole('dialog').querySelector('img')!, { clientX: 31, clientY: 42 }) + expect(openImageMenu).toHaveBeenLastCalledWith({ kind: 'image', value: { url: 'blob:original', name: 'history.png', mediaType: attachment.mediaType } }, { x: 31, y: 42 }) +}) diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index a14099543e..706dc4531b 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('contextMenus', { open: vi.fn() } as never) ctx.provide('locale', new LocaleRuntime(ctx)) await ctx.plugin(SlotRegistry).await() ctx.slots.register({ @@ -33,11 +34,13 @@ async function bench() { 'tool.call.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) - ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) + const readFileDrag = vi.fn(() => undefined) + ctx.provide('fileDrag', { actions: fileDrag, read: readFileDrag }) + const register = vi.spyOn(ctx.slots, 'register') const registerFactory = vi.spyOn(ctx.slots, 'registerFactory') const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, fiber, registerFactory } + return { ctx, fiber, register, registerFactory, readFileDrag } } describe('attachment plugin', () => { @@ -46,7 +49,7 @@ describe('attachment plugin', () => { }) it('registers all entries and removes them with the plugin fiber', async () => { - const { ctx, fiber, registerFactory } = await bench() + const { ctx, fiber, register, registerFactory, readFileDrag } = await bench() expect(inject).toEqual(['slots', 'fileDrag']) expect(registerFactory.mock.calls[0]?.[0]).toMatchObject({ name: 'conversation.images.content', scope: 'root', locale: 'conversation', @@ -57,6 +60,10 @@ describe('attachment plugin', () => { expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{ locale: 'conversation', component: ComposerAttachments, }]) + const composer = register.mock.calls.find(([options]) => options.name === 'conversation.input.attachments')! + const injectComposer = composer[0].inject as () => { readFileDrag: () => undefined } + injectComposer().readFileDrag() + expect(readFileDrag).toHaveBeenCalledOnce() for (const name of IMAGE_SLOTS) { expect(ctx.slots.entries(name)).toMatchObject([{ component: MessageImages, options: { priority: 100 } }]) } diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 6d6e4792bc..853d6a70e2 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 d182421a5d..ea9a48c09a 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: be90c24392fb021b + zh: 992b8745b385f9ab /deepseek-ai-dsh-client-ui-chat/turn-token-usage: en: cfdfe3d99d3f4e09 zh: 9330bad3d3f7a689 @@ -36,8 +36,8 @@ en: 3afad0b4c6bf9ce0 zh: d8179ba4ee9fcd61 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 91e1f5f12ca51211 - zh: b92090966d58b82c + en: dd4e69828096c3a0 + zh: 2a9bc370f44efcd7 /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 c9d97805f3..99b01c5566 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -63,6 +63,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. +Chat adapts Markdown targets at `MarkdownDelegateProvider.getTriggerProps`. The assistant-node Slot obtains `useContextMenu` directly and passes a file-path adapter to the optional mention service; intermediate flow and node owners carry no menu callbacks. Paths resolve against the originating Session’s current working directory when the gesture begins. Menus retain that captured path and line even if focus changes. Undeclared plugin links keep native behavior. + ----- @@ -184,6 +186,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, images, reference chips, and tool-file controls 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 95ff3aacb5..35231f8e5e 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -63,6 +63,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 进程在轮次中途退出,不显示代码标签,其过程摘要显示“已停止”。 +Chat 在 `MarkdownDelegateProvider.getTriggerProps` 接入处适配 Markdown 目标。助手节点 Slot 直接获取 `useContextMenu`,向可选的文件引用服务传入路径适配函数;消息流和节点中间层不再携带菜单回调。手势发生时按原始 Session 的当前工作目录解析路径,菜单保留捕获的路径和行号,不受焦点变化影响。未声明的插件链接保留原生行为。 + ----- @@ -184,6 +186,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 文件、链接、图片、引用 chip 和工具文件控件将明确的右键目标转发给 [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 886912aa6c..c8a468b48c 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 fbabf95903..d3a6df8b96 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' @@ -25,11 +26,12 @@ import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ChatFlowDataInjected, ChatFlowInjected, ChatNodeInjected, ChatScrollPosition, ChatViewInjected, - QuotaNoticeInjected, QuotaNoticeState, TurnTailOwnerProps, + QuotaNoticeInjected, QuotaNoticeState, } from './contract/slots.ts' import type { ChatSnapshot } from './contract/snapshot.ts' import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts' import { ApprovalCommand } from './chat/ApprovalCommand.tsx' +import { sessionFilePath } from './file-path.ts' import { ChatView } from './chat/ChatView.tsx' import { ChatFlow } from './chat/ChatFlow.tsx' import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' @@ -253,7 +255,6 @@ export function apply(ctx: Context): void { fileDrag: ctx.fileDrag.actions, hooks: { presentation, interactionIds: ctx.uiConversation.interactions }, dispatchInteraction: gesture => ctx.uiConversation.interactions.dispatch(sessionId, gesture), - fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId), // Files open in the right Sidebar, not in a desktop application: the // content stays in the product, beside the conversation that produced // it. A relative path resolves from the Session's working directory @@ -276,6 +277,7 @@ export function apply(ctx: Context): void { else ctx.sidebarRight.openResource(url, { params: { line: options.line } }) await Promise.resolve() }, + resolveFilePath: sessionFilePath(ctx, sessionId), 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/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 455f90f9a2..ad5963c093 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -1,16 +1,28 @@ import { memo, useCallback, useMemo } from 'react' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import type { ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from '../contract/slots.ts' +import type { ChatFileMentions, ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from '../contract/slots.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' -type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace +/** Business readers owned by the assistant renderer's existing Slot registration. */ +export interface AssistantNodeInjected extends PresentationInjected { + /** Resolve against the live Session directory. @param path - Recorded path. @returns Host path. */ + resolveFilePath: (path: string) => string + /** Resolve closing-Turn mentions with the supplied ordinary file gesture callback. */ + fileMentions: (owner: TurnTailOwnerProps, getFileTriggerProps: NonNullable[2]>) => ReturnType +} + +type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace & PropsRenderSlots<'conversation.chat.reasoning.body' | 'conversation.chat.message.images'> /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ node, groupPart, useDisclosure, useGroupAction, useTurnData, turnProcess, openFile, loadImage, fileMentions, - usePresentation, renderSlot, t, + usePresentation, renderSlot, t, sessionId, useContextMenu, resolveFilePath, }: AssistantNodeViewProps) { + const { getTriggerProps } = useContextMenu() + const getFileTriggerProps = useCallback((path: string) => getTriggerProps(() => ({ + kind: 'file', value: { sessionId, path: resolveFilePath(path) }, + })), [getTriggerProps, sessionId, resolveFilePath]) const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn @@ -22,8 +34,8 @@ export const AssistantNodeView = memo(function AssistantNodeView({ return { turn, seq: data.finalNode.seq, openFile } }, [data.finalNode, openFile, tail, turn]) const mentions = useMemo( - () => owner === undefined ? undefined : fileMentions(owner), - [fileMentions, owner], + () => owner === undefined ? undefined : fileMentions(owner, getFileTriggerProps), + [fileMentions, owner, getFileTriggerProps], ) const keepReplies = usePresentation(policy => policy.foldedReplies === 'all') // A kept reply row stays visible while the folded Turn hides its inline reasoning. diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx index 86bcdaa542..d83a3043cc 100644 --- a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx @@ -27,7 +27,7 @@ export const ChatFlow = memo(function ChatFlow({ entries, pendingInputs, lastInputTurn, deferCollapse, useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, useConnectionState, useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction, - cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, + cwd, openFile, inspectCall, forkAt, loadImage, }: ChatFlowSlotProps) { const nodeStore = useChat(snapshot => snapshot.nodes) const running = useSession(snapshot => snapshot.running) @@ -47,7 +47,7 @@ export const ChatFlow = memo(function ChatFlow({ const seatProps = { nodeStore, useChatNode, useChatNodeBottom, useChatNodeProcess, usePresentation, useStore, actions, renderSlot, t, useGroupAction, deferCollapse, - cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, + cwd, openFile, inspectCall, forkAt, loadImage, } const rows = entries.map((entry) => { switch (entry.kind) { diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 6db2f75821..4efdb94142 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -50,7 +50,7 @@ function turnOf(node: ChatNode | undefined): number | undefined { export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation, cwd, openFile, inspectCall, forkAt, - loadImage, fileMentions, useStore, actions, renderSlot, t, useChatNodeBottom, deferCollapse, useGroupAction, + loadImage, useStore, actions, renderSlot, t, useChatNodeBottom, deferCollapse, useGroupAction, }: ChatNodeSeatProps) { const bottom = useChatNodeBottom(nodeKey) const node = useChatNode(nodeKey) @@ -138,11 +138,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ inspectCall, forkAt, loadImage, - fileMentions, turnProcess, }, [ node, groupPart, cwd, openFile, inspectCall, forkAt, - loadImage, fileMentions, turnProcess, + loadImage, turnProcess, ]) if (routedNode === undefined || owner === null) return null // Runtime dispatch owns the correlation: every Node's discriminant is the diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 703fe65449..e265ab635f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -8,7 +8,7 @@ import type { import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import { - Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, + Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, type MarkdownMenuTarget, } from '@deepseek-ai/dsh-client-ui-primitives' import { isAtOrOutside } from '../contract/group-tree.ts' import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' @@ -60,9 +60,22 @@ 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, resolveFilePath, useContextMenu, + loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, usePresentation, useProjection, useInteractionIds, dispatchInteraction, t, fileDrag, }: ChatViewSlotProps) { + const { getTriggerProps } = useContextMenu() + const getMarkdownTriggerProps = useCallback((target: MarkdownMenuTarget) => getTriggerProps(() => { + switch (target.kind) { + case 'file': return { kind: 'file', value: { sessionId, path: resolveFilePath(target.path), + ...(target.line === undefined ? {} : { line: target.line }) } } + case 'url': return { kind: 'url', value: { sessionId, url: target.url } } + case 'image': return { kind: 'image', value: { sessionId, url: target.url, + ...(target.name === undefined ? {} : { name: target.name }), + ...(target.mediaType === undefined ? {} : { mediaType: target.mediaType }), + ...(target.path === undefined ? {} : { path: resolveFilePath(target.path) }) } } + } + }), [getTriggerProps, resolveFilePath, sessionId]) const order = useChat(s => s.order) const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries) const entries = useMemo(() => groupedEntries @@ -230,10 +243,15 @@ export function ChatView({ )} - + {renderSlot('conversation.chat.flow', { entries, pendingInputs, lastInputTurn, deferCollapse, - cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, fileMentions, + cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, }, { hookContext: flowContext })}
({ hooks: { presentation } }), + inject: (sessionId): AssistantNodeInjected => ({ + hooks: { presentation }, + resolveFilePath: sessionFilePath(ctx, sessionId), + fileMentions: (owner, getFileTriggerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId, getFileTriggerProps), + }), }, AssistantNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 96028b0ffd..9479bde696 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -17,7 +17,9 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { ConnectionStateSource } from '@deepseek-ai/dsh-client-connection/client' -import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { FileDragActions, MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client' +import type { ChatKey } from '../locale.ts' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read. import type {} from '@deepseek-ai/dsh-client-ui-session/client' @@ -136,9 +138,12 @@ export interface ChatFileMentions { * Resolve prose links for one closing Turn. * @param owner - closing-Turn identity and file opener. * @param sessionId - viewed Session, including when history is inherited from a fork. + * @param getFileTriggerProps - ordinary gesture binding for a resolved file path. * @returns link resolver when available. */ - forClosing(owner: TurnTailOwnerProps, sessionId: SessionId): MarkdownFileMentions | undefined + forClosing( + owner: TurnTailOwnerProps, sessionId: SessionId, getFileTriggerProps?: (path: string) => ContextMenuTriggerProps, + ): MarkdownFileMentions | undefined } declare module '@deepseek-ai/cordis' { @@ -233,7 +238,6 @@ export interface ChatNodeOwnerProps { forkAt: (seq: number) => void /** Session-authorized image loader supplied by the Chat view. */ loadImage: MessageImageLoader - fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined /** Turn-process state when this Node belongs to a projected Turn. */ turnProcess?: TurnProcessOwnerProps | undefined } @@ -313,7 +317,9 @@ export interface PerformanceUsageInjected { /** Business callbacks injected into the Chat view. */ export interface ChatViewInjected { /** File preparation and failure callbacks owned by the file-drag plugin. */ - readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions + readonly fileDrag: FileDragActions + /** Resolve the reference against this Session and its current path base when the gesture begins. */ + resolveFilePath: (path: string) => string /** Dispatch one gesture to the Conversation plugin registry. */ dispatchInteraction: (gesture: TextInteractionGesture) => string | null hooks: { @@ -344,7 +350,6 @@ export interface ChatViewInjected { read: () => ChatScrollPosition | null } forkAt: (seq: number) => void - fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } /** Session-owned node sources shared with the view, plus the flow's connection state. */ @@ -357,7 +362,7 @@ export type ChatFlowDataInjected = Pick & { /** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */ export interface ChatFlowOwnerProps extends Pick { + 'cwd' | 'openFile' | 'inspectCall' | 'forkAt' | 'loadImage'> { readonly entries: readonly RenderEntry[] readonly pendingInputs: readonly (PendingSubmission | InboxState['next-step'][number])[] readonly lastInputTurn: number | undefined @@ -393,7 +398,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { /** Chat target, transcript node, statistics, and details copy. */ - chat: import('../locale.ts').ChatKey + chat: ChatKey } interface SlotFactoryMap { diff --git a/packages/client/ui-chat/src/client/file-path.ts b/packages/client/ui-chat/src/client/file-path.ts new file mode 100644 index 0000000000..9c23fdc21d --- /dev/null +++ b/packages/client/ui-chat/src/client/file-path.ts @@ -0,0 +1,19 @@ +/** Gesture-time path resolution for Chat-owned file references. */ +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' + +/** + * Bind path resolution to the originating Session without capturing its current directory. + * @param ctx - Chat plugin context. + * @param sessionId - Original Session identity. + * @returns Resolver using the live working directory, falling back to the workspace root. + */ +export function sessionFilePath(ctx: Context, sessionId: SessionId): (path: string) => string { + return (path) => { + const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd + const current = ctx.sessions.binding(sessionId)?.session.projections.faceOf('workingDirectory').getSnapshot() as string | null | undefined + return resolveWorkspacePath(current ?? cwd, path) + } +} diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index e56a8e0a7a..c785329f0a 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 9687743249..8ee48b8f65 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,6 @@ +import type { AssistantNodeInjected } from '../src/client/chat/AssistantNodeView.tsx' 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 +102,8 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' }, @@ -314,9 +318,15 @@ describe('Chat inject API', () => { it('resolves relative file paths against the Session\'s current working directory', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) + vi.spyOn(b.runtime.ctx.contextMenus, 'open') await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w') await injected.openFile('src/a.ts') expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts') + expect(injected.resolveFilePath('src/a.ts')).toBe('/proj/.agents/worktrees/w/src/a.ts') + await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', null) + expect(injected.resolveFilePath('file.ts')).toBe('/proj/file.ts') + await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w') + // A leading `./` names the same file, so the Sidebar reuses its tab. await injected.openFile('./src/a.ts') expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts') @@ -515,12 +525,16 @@ describe('Chat inject API', () => { expect(flow.injected.hooks.connectionState).toBe(b.runtime.ctx.get('connection')!.state) expect(flow.injected.hooks.presentation).toBe(injected.hooks.presentation) - expect(injected.fileMentions(owner)).toBeUndefined() + const entry = b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'assistant-step')! + const assistant = entry.inject!(ROOT as never) + const fileMentions = assistant.fileMentions as AssistantNodeInjected['fileMentions'] + const getFileTriggerProps = vi.fn(() => ({})) + expect(fileMentions(owner, getFileTriggerProps)).toBeUndefined() const mentions = { resolve: vi.fn() } as never const forClosing = vi.fn(() => mentions) b.runtime.ctx.provide('chatFileMentions', { forClosing } as never) - expect(injected.fileMentions(owner)).toBe(mentions) - expect(forClosing).toHaveBeenCalledWith(owner, ROOT) + expect(fileMentions(owner, getFileTriggerProps)).toBe(mentions) + expect(forClosing).toHaveBeenCalledWith(owner, ROOT, getFileTriggerProps) expect(injected.chatScroll.read()).toBeNull() const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 } 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 066a441068..3e61dce8bf 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,8 @@ async function bench() { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + 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 5f8de03ab8..2bc8286e93 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import type { ProcessGroupData } from '../src/client/contract/process-groups.ts' import type { InboxState } from '@deepseek-ai/dsh-agent/types' @@ -7,6 +8,7 @@ import type { GlobalStandardProps, PropsRenderFactories, PropsRenderSlots } from import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useEffect, useMemo } from 'react' +import { useMarkdownDelegate, type MarkdownMenuTarget } from '@deepseek-ai/dsh-client-ui-primitives' import type { AssistantMessageNode, ChatFlowHookContext, ChatFlowOwnerProps, ChatFlowSlotProps, ChatNode, ChatNodeHookContext, ChatNodeOwnerProps, ChatSnapshot, UserBeforeOwnerProps, @@ -267,7 +269,7 @@ type ImageRenderer = (owner: MessageImagesOwnerProps) => React.ReactNode declare const userChainDispatch: PropsRenderSlots<'conversation.chat.user.before'>['renderSlotChain'] type UserChainRenderer = typeof userChainDispatch<'conversation.chat.user.before'> type UserChainState = { readonly renderer: UserChainRenderer | undefined } -type HarnessStandardProps = ChatViewSlotProps & PropsRenderFactories +type HarnessStandardProps = ChatViewSlotProps & PropsRenderFactories & Pick, 'fileMentions'> function isImageOwner(owner: object): owner is MessageImagesOwnerProps { return 'images' in owner && Array.isArray(owner.images) @@ -492,6 +494,7 @@ function makeHarness( const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: HarnessStandardProps = { fileDrag, + resolveFilePath: vi.fn((path: string) => path), renderFactorySlot, usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, @@ -505,6 +508,7 @@ function makeHarness( useTrajectory: (() => { throw new Error('unused') }), useSessions: emptySessions(), useSessionRetainInfo: () => undefined, + useContextMenu: contextMenuHook(), useResource, useSessionStatus: bindSnapshotSelector( createSnapshotStore(new Map()), @@ -706,6 +710,41 @@ function formatRunDuration(ms: number, t: Parameters[1]): describe('Chat node rendering', () => { + it('adapts Markdown targets with the original Session and resolves file paths when each gesture begins', () => { + const h = makeHarness() + const opened = vi.fn() + let directory = '/workspace' + h.props.useContextMenu = contextMenuHook(opened) + h.props.resolveFilePath = path => `${directory}/${path}` + const targets: MarkdownMenuTarget[] = [ + { kind: 'file', path: 'src/a.ts', line: 24 }, + { kind: 'file', path: 'src/b.ts' }, + { kind: 'url', url: 'https://example.com/page' }, + { kind: 'image', url: 'blob:remote' }, + { kind: 'image', url: 'blob:local', path: 'image.svg', name: 'Image', mediaType: 'image/svg+xml' }, + ] + function References() { + const { getTriggerProps } = useMarkdownDelegate() + return <>{targets.map((target, index) => )} + } + const view = render( } />) + directory = '/workspace/moved' + for (const [index, target] of targets.entries()) { + const button = view.getByRole('button', { name: String(index) }) + button.getBoundingClientRect = () => new DOMRect(10, 20, 30, 40) + fireEvent.contextMenu(button, { clientX: 50, clientY: 60 }) + const { kind, ...value } = target + const expected = { kind, value: { + sessionId: SID, ...value, ...'path' in target ? { path: `${directory}/${target.path}` } : {}, + } } + expect(opened).toHaveBeenLastCalledWith(expected, { x: 50, y: 60 }) + fireEvent.keyDown(button, { key: 'F10', shiftKey: true }) + expect(opened).toHaveBeenLastCalledWith(expected, { x: 10, y: 60 }) + } + expect(opened).toHaveBeenCalledTimes(10) + expect(h.openFile).not.toHaveBeenCalled() + }) + it('opens Markdown references to unmodified files with line navigation', () => { const h = makeHarness({ nodes: [user(1, 'explain'), assistant(2, '[source](src/index.ts#L24-L30)', 1)], @@ -729,13 +768,18 @@ describe('Chat node rendering', () => { ], turnEnds: new Map([[1, 4]]), }) - h.props.fileMentions = owner => ({ + const opened = vi.fn() + let directory = '/workspace' + h.props.useContextMenu = contextMenuHook(opened) + h.props.resolveFilePath = path => `${directory}/${path}` + h.props.fileMentions = (owner, getFileTriggerProps) => ({ resolve: (value) => { if (value !== 'report.html') return undefined return { open: () => { void h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, label: '打开 site/report.html', title: 'site/report.html', + contextMenuProps: getFileTriggerProps('site/report.html'), } }, }) @@ -746,6 +790,17 @@ describe('Chat node rendering', () => { expect(mentions).toHaveLength(1) const mention = view.getByRole('button', { name: '打开 site/report.html' }) expect(mention.getAttribute('title')).toBe('site/report.html') + fireEvent.contextMenu(mention, { clientX: 12, clientY: 34 }) + expect(opened).toHaveBeenLastCalledWith({ + kind: 'file', value: { sessionId: SID, path: '/workspace/site/report.html' }, + }, { x: 12, y: 34 }) + directory = '/workspace/moved' + fireEvent.keyDown(mention, { key: 'F10', shiftKey: true }) + expect(opened).toHaveBeenLastCalledWith({ + kind: 'file', value: { sessionId: SID, path: '/workspace/moved/site/report.html' }, + }, { x: 0, y: 0 }) + expect(opened).toHaveBeenCalledTimes(2) + expect(h.openFile).not.toHaveBeenCalled() fireEvent.click(mention) // The vocabulary was built from the closing message's own owner currency. expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html') @@ -4699,3 +4754,21 @@ const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () it('formats Portuguese elapsed minutes with numeric units', () => { expect(formatRunDuration(90_000, key => pt[key])).toBe('1m 30s') }) + +it('does not infer menu targets from links or data attributes inside a plugin flow slot', () => { + const h = makeHarness() + const custom = vi.fn() + const view = render( <> + Plugin link + + + } />) + expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true) + expect(fireEvent.contextMenu(view.getByRole('button', { name: 'Undeclared file' }))).toBe(true) + fireEvent.contextMenu(view.getByRole('button', { name: 'Custom target' })) + expect(custom).toHaveBeenCalledOnce() + expect(h.props.resolveFilePath).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-chat/tests/context-menu.client.spec.tsx b/packages/client/ui-chat/tests/context-menu.client.spec.tsx new file mode 100644 index 0000000000..545538b18c --- /dev/null +++ b/packages/client/ui-chat/tests/context-menu.client.spec.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { MarkdownDelegateProvider, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +it('leaves undeclared plugin links alone and binds only explicitly rendered Markdown targets', () => { + const open = vi.fn() + const custom = vi.fn() + const view = render( {}}> + Plugin link + + + ) + expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true) + expect(open).not.toHaveBeenCalled() + const file = view.getByRole('button', { name: 'File' }) + fireEvent.contextMenu(file, { clientX: 40, clientY: 50 }) + expect(open).toHaveBeenLastCalledWith({ kind: 'file', path: 'docs/file.ts', line: 42 }, { x: 40, y: 50 }) + file.getBoundingClientRect = () => new DOMRect(30, 40, 100, 20) + fireEvent.keyDown(file, { key: 'F10', shiftKey: true }) + expect(open).toHaveBeenLastCalledWith({ kind: 'file', path: 'docs/file.ts', line: 42 }, { x: 30, y: 60 }) + expect(document.activeElement).toBe(file) + fireEvent.contextMenu(view.getByRole('button', { name: 'Plugin target' }), { clientX: 70, clientY: 80 }) + expect(custom).toHaveBeenCalledExactlyOnceWith({ kind: 'pluginDocument', id: 'one' }, { x: 70, y: 80 }) + expect(open).toHaveBeenCalledTimes(2) +}) diff --git a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx index c7f67f3505..5e0f5cd8e3 100644 --- a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx +++ b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx @@ -3,6 +3,7 @@ * that outlives the Chat panel, and history scrollback never does. */ // @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 { afterEach, describe, expect, it } from 'vitest' import { act, screen } from '@testing-library/react' @@ -68,6 +69,8 @@ async function bench() { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.declare({ 'shell.overlay': { kind: 'list', scope: 'root' }, 'conversation.approval.detail': { kind: 'single', scope: 'session' }, diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index f67f18eb92..ca6d67f0f3 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' @@ -50,6 +51,7 @@ function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | ty useSessionRetainInfo: () => undefined, useResource, ...allowAllAccess, + useContextMenu: contextMenuHook(), useTranscriptView: bindSnapshotSelector(source), useCollapseTiming: bindSnapshotSelector(collapseTiming), useFoldedReplies: bindSnapshotSelector(foldedReplies), diff --git a/packages/client/ui-chat/tests/user-content.client.spec.tsx b/packages/client/ui-chat/tests/user-content.client.spec.tsx index 1a93864a41..38a786beee 100644 --- a/packages/client/ui-chat/tests/user-content.client.spec.tsx +++ b/packages/client/ui-chat/tests/user-content.client.spec.tsx @@ -1,3 +1,4 @@ +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' // @vitest-environment jsdom import { useState, type ReactNode } from 'react' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' @@ -54,6 +55,7 @@ async function bench() { const openFile = vi.fn<(path: string) => void>() const openSkill = vi.fn<(name: string) => void>() await runtime.mount({ inject: ['slots'], apply: (ctx) => { + ctx.slots.provideRoot({ props: { useContextMenu: contextMenuHook() } }) const userText = new UserTextRegistry(ctx) for (const rule of [ sessionReferenceRule, fileReferenceRule, folderReferenceRule, skillReferenceRule, @@ -75,7 +77,7 @@ async function bench() { ctx.slots.register({ name: 'conversation.user.text.node', key: 'text' }, PlainUserText) ctx.slots.register({ name: 'conversation.user.text.node', key: 'session' }, SessionReference) ctx.slots.register({ name: 'conversation.user.text.node', key: 'file', locale: 'reference', - inject: () => ({ openFile }) }, FileReference) + inject: () => ({ openFile, resolveFilePath: path => path }) }, FileReference) ctx.slots.register({ name: 'conversation.user.text.node', key: 'folder' }, FolderReference) ctx.slots.register({ name: 'conversation.user.text.node', key: 'skill', inject: () => ({ openSkill }) }, SkillReference) ctx.slots.inject('conversation.chat.user.images', () => ctx.slots.register({ name: 'conversation.chat.user.images' }, function ImageProbe(props) { diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json index 42e4b6003c..4040aacd08 100644 --- a/packages/client/ui-chat/tsconfig.client.json +++ b/packages/client/ui-chat/tsconfig.client.json @@ -122,6 +122,9 @@ }, { "path": "../../core/tool-executor-ptc" + }, + { + "path": "../ui-context-menu" } ], "exclude": [ diff --git a/packages/client/ui-context-menu/README.i18n.yaml b/packages/client/ui-context-menu/README.i18n.yaml new file mode 100644 index 0000000000..1f54beccbb --- /dev/null +++ b/packages/client/ui-context-menu/README.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for README.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 packages/client/ui-context-menu/README.md +/: + en: c7a386986f8aa0d7 + zh: a4bace64648d0a89 +/deepseek-ai-dsh-client-ui-context-menu: + en: f165f99dfbea7ed2 + zh: 0f2835e7726dd0f5 +/deepseek-ai-dsh-client-ui-context-menu/summary: + en: 7d22e21395a76909 + zh: e9f98068a4c5fde7 +/deepseek-ai-dsh-client-ui-context-menu/table-of-contents: + en: d152484eb41ac6b4 + zh: 725b47765ee8f30b +/deepseek-ai-dsh-client-ui-context-menu/use-this-package: + en: c84c65c7d24d51c3 + zh: 98c8f40a326a8f0b +/deepseek-ai-dsh-client-ui-context-menu/use-this-package/register-actions: + en: 11bea09005de2678 + zh: d56e25d2d93022aa +/deepseek-ai-dsh-client-ui-context-menu/understand-the-implementation: + en: 5b7717fad8457145 + zh: 19a06fca8060cf62 +/deepseek-ai-dsh-client-ui-context-menu/further-exploration: + en: 6d538e70a88f070b + zh: f90577fe9c8d1799 +/deepseek-ai-dsh-client-ui-context-menu/model-experience: + en: bb3787b3201a6724 + zh: e51cbb66f42a433d +/deepseek-ai-dsh-client-ui-context-menu/model-experience/kv-cache-effect: + en: e5fe67ca0631f67a + zh: b26e6107f2638589 +/deepseek-ai-dsh-client-ui-context-menu/known-limitations-and-deferred-work: + en: ac1e5554e7f3109f + zh: c2df38df3b9ea4f2 +/deepseek-ai-dsh-client-ui-context-menu/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 6c5f9788c6536776 diff --git a/packages/client/ui-context-menu/README.md b/packages/client/ui-context-menu/README.md new file mode 100644 index 0000000000..dff6ea78cc --- /dev/null +++ b/packages/client/ui-context-menu/README.md @@ -0,0 +1,100 @@ +--- +description: "Add actions to right-click menus without importing a React component" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-context-menu + +English | [中文](README.zh.md) + +## Summary + +Add actions to right-click menus without importing a React component. Targets retain the clicked Session, Workspace, file, directory, URL, image, or pane even when selection changes. Contributions can discover metadata asynchronously and disappear safely when their plugin unloads. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Web application bundle mounts this Client plugin. External Client plugins declare the package in `dsh.client.inject`, import its `/client` types, and inject the `contextMenus` service. `resolutionTimeoutMs` bounds each provider’s preparation (1000ms by default); timed-out providers are aborted and omitted with visible failure feedback. + +The plugin injects `useContextMenu` into every Slot scope through `ctx.slots.provideRoot`. Call `const { getTriggerProps } = useContextMenu()` once in the component and spread `getTriggerProps(target, handlers?)` onto each trigger. The factory is an ordinary function and can run in loops. It handles right-click, Shift+F10, the ContextMenu key, viewport coordinates, and focus without scrolling. Existing handlers run first; `preventDefault()` keeps their ownership and prevents duplicate handling by composed or nested bindings. Repeated, composing, and unrelated key events remain untouched. Pass `undefined` to retain native behavior, or a target resolver to read business state when the gesture begins. The resolved object is captured before focus changes. Plain children receive event props or a factory through props or an existing delegate; no additional React Context is needed. Special editors and canvases can call `ctx.contextMenus.open()` directly. + +### Register actions + +Call `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))` in the contributing plugin. `resolve` receives the captured target and its cancellation signal; return localized actions with stable IDs, optional icons, disabled state, submenus, and execution callbacks. Contributions sort by `order`, then ID. Duplicate contribution IDs within one target family fail registration; duplicate action paths reject that provider’s result. IDs are encoded by segment before publication. + +An action’s optional `commandId` associates it with an existing shortcut command. The overlay subscribes to `ctx.shortcuts.catalog`, including configured, removed, and reset bindings; nested and footer actions use the same presentation. This association never dispatches the command: selection invokes only the action’s captured `run` callback. The menu plugin requires the `shortcuts` service. + +A contribution can declare `observe(target)` sources for permissions, membership, or localized labels. Source updates re-resolve that contribution against the same captured target and replace its callbacks; outdated asynchronous preparations are cancelled. Dismissal and plugin unload release every subscription. + +Extend `ContextMenuTargetMap` through declaration merging on `@deepseek-ai/dsh-client-ui-context-menu/client` for a new target family; `ContextMenuTarget` derives the `{ kind, value }` union from that map. Open a target with `ctx.contextMenus.open(target, { x, y })`. Keep target values as immutable JSON-compatible identifiers and metadata. Execution callbacks capture that value; they must recheck mutable resources before acting. New registrations participate in the next opening. Unregistering removes visible actions and cancels pending metadata. + +`ctx.contextMenus.resolve(target)` gives a separate occurrence for another renderer. Subscribe to its immutable action views, select a published ID, and dispose it when its owner closes. Views contain presentation fields only. Callbacks remain in the registry and execute at most once. Dismissal and replacement cancel metadata, but an action already selected owns its own execution and cancellation. Rejected providers and actions produce a persistent overlay toast; an action’s localized `failureLabel` supplies operation-specific recovery guidance. + +`ctx.contextMenus.notify(text, success, action?)` can attach a localized text action through the shared Toast. The action provides `label`, `run`, and `failureLabel`; clicking dismisses the toast before execution, and rejection reports its failure label. A dismissed or replaced toast cannot execute its action again. + +Shared popups wait for every contribution to settle or time out before appearing. A repeated gesture within four CSS pixels closes the same target; a farther gesture replaces it with a separate fade. `ready` gives custom renderers the same preparation point. Structured action icons align within each divider group; component contributions retain their own layout. Normal dismissal retains the popup for 80ms, replacement for 10ms; opening waits at least 20ms and fades in over 40ms. Reduced motion suppresses the fade. + +Actions carry `icon?: { key: string; src?: string }`. Register the matching glyph component with `ctx.slots.inject('contextMenu.icon', () => ctx.slots.register({ name: 'contextMenu.icon', key: 'my-plugin.action' }, Icon))`. The overlay dispatches `renderSlot` by that key for ordinary, nested, and footer actions; the component receives the captured `icon` metadata, including an optional image URL. Registrations follow the contributing plugin and menu Slot declaration lifetimes. Action views contain no JSX nodes. + +The optional keyed `contextMenu.content` Slot hosts component contributions. Existing Session and tab Slot names remain owned by their feature adapters. Keyboard context keys and Shift+F10 use the focused trigger’s lower edge. `afterContent` places a data action after those rows; ordinary actions render before them. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The registry separates resolution and callback lifetimes from presentation. The shared overlay uses the existing Menu primitive for keyboard movement, focus return, dismissal, submenus, and viewport positioning. [Registry](src/client/registry.ts) owns cancellation and ordering; [target and action types](src/client/types.ts) own the public fields. + +
+ +----- + + +## Further Exploration + +- [Slots](../ui-slots/README.md) — component contributions. +- [File actions](../ui-file-actions/README.md) — original content transfer. +- [Open in app](../ui-open-in-app/README.md) — shipped resource actions. + +----- + + +## Model Experience + +None, as popup presentation and registration do not construct model input. + +#### KV Cache effect + +None; contributed feature actions own any subsequent model interaction. + +## Known Limitations and Deferred Work + + + +- The shipped renderer is the shared Web Menu, including inside Desktop. Native menu rendering is not supplied. +- Component-only legacy actions do not appear in the plain-data resolution API. +- Menus are window-local and transient; they are not persisted in Session data. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-context-menu/README.zh.md b/packages/client/ui-context-menu/README.zh.md new file mode 100644 index 0000000000..78f154feae --- /dev/null +++ b/packages/client/ui-context-menu/README.zh.md @@ -0,0 +1,100 @@ +--- +description: "无需导入 React 组件即可向右键菜单添加操作" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-context-menu + +[English](README.md) | 中文 + +## 概述 + +无需导入 React 组件即可向右键菜单添加操作。即使选中项变化,目标仍保留被点击的会话、工作区、文件、文件夹、链接、图片或窗格。贡献项可以异步查询元数据,并在插件卸载时安全移除。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延后工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.client.inject` 声明本包,导入 `/client` 类型,并注入 `contextMenus` 服务。`resolutionTimeoutMs` 限制每个提供方的准备时长(默认 1000ms);超时后取消并忽略该提供方,显示失败提示。 + +插件通过 `ctx.slots.provideRoot` 向每个 Slot 作用域注入 `useContextMenu`。组件调用一次 `const { getTriggerProps } = useContextMenu()`,再将 `getTriggerProps(target, handlers?)` 展开到各个触发元素上。该工厂是普通函数,可以在循环中调用;它统一处理右键、Shift+F10、ContextMenu 键、视口坐标及无滚动聚焦。已有处理器先运行,`preventDefault()` 保留其处理权,并防止组合或嵌套绑定重复处理。重复、输入法组合及无关按键保持原状。传入 `undefined` 保留原生行为,也可传入目标解析函数,在手势开始时读取业务状态。解析后的对象会在焦点变化前捕获。普通子组件通过 props 或现有 delegate 接收事件 props 或工厂,无需额外 React Context。特殊编辑器和 Canvas 可直接调用 `ctx.contextMenus.open()`。 + +### 注册操作 + +在贡献插件中调用 `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))`。`resolve` 接收捕获的目标和取消信号;返回本地化操作,包含稳定 ID、可选图标、禁用状态、子菜单和执行回调。贡献项先按 `order`、再按 ID 排序。同类目标的贡献 ID 重复会导致注册失败;操作路径重复会拒绝该提供方的结果。发布前会逐段编码 ID。 + +动作可通过可选 `commandId` 关联已有快捷键命令。浮层订阅 `ctx.shortcuts.catalog`,跟随已配置、移除和重置的绑定更新;子菜单和底部动作采用相同展示。此关联不会调度命令:选择菜单项只调用该动作捕获的 `run` 回调。菜单插件依赖 `shortcuts` 服务。 + +贡献可通过 `observe(target)` 声明权限、成员状态或本地化标签的数据源。数据源更新时,针对同一捕获目标重新解析该贡献并替换回调;过期的异步准备会被取消。关闭菜单和卸载插件都会释放所有订阅。 + +通过对 `@deepseek-ai/dsh-client-ui-context-menu/client` 的声明合并扩展 `ContextMenuTargetMap`,即可增加目标类别;`ContextMenuTarget` 从该映射派生 `{ kind, value }` 联合类型。通过 `ctx.contextMenus.open(target, { x, y })` 打开目标。目标值使用不可变、可 JSON 序列化的标识与元数据。执行回调捕获该值,并在操作前重新检查可变资源。新注册项在下次打开时生效。注销会移除可见操作并取消待处理的元数据查询。 + +`ctx.contextMenus.resolve(target)` 为其他渲染器提供独立菜单实例。订阅不可变操作视图,选择已发布的 ID,并在所属界面关闭时释放。视图仅包含展示字段。回调保留在注册表中,最多执行一次。关闭和替换菜单会取消元数据查询,已选中操作则自行管理执行与取消。提供方或操作拒绝时由持久的覆盖层提示;操作的本地化 `failureLabel` 提供具体的恢复建议。 + +`ctx.contextMenus.notify(text, success, action?)` 可通过共享 Toast 附加本地化文字操作。操作提供 `label`、`run` 和 `failureLabel`;点击后先关闭提示再执行,失败时显示对应文案。已关闭或被替换的提示不能再次执行其操作。 + +共享弹窗等待全部贡献项完成或超时后才显示。同一目标在四个 CSS 像素以内重复触发会关闭菜单;更远的触发以独立淡入淡出替换菜单。`ready` 向自定义渲染器提供同一个准备完成时点。结构化操作的图标按分隔线组对齐;组件贡献保留自己的布局。普通关闭保留弹窗 80ms,替换保留 10ms;打开至少等待 20ms,再用 40ms 淡入。减少动态效果偏好会禁用淡入淡出。 + +操作携带 `icon?: { key: string; src?: string }`。通过 `ctx.slots.inject('contextMenu.icon', () => ctx.slots.register({ name: 'contextMenu.icon', key: 'my-plugin.action' }, Icon))` 注册匹配的图标组件。覆盖层对普通项、子菜单和底部操作都按该 key 调用 `renderSlot`;组件接收已捕获的 `icon` 元数据,包括可选图片 URL。注册随贡献插件和菜单 Slot 声明的生命周期清理。操作视图不包含 JSX 节点。 + +可选的 keyed Slot `contextMenu.content` 承载组件贡献。现有会话和标签菜单 Slot 名称仍由各自功能适配器维护。键盘菜单键和 Shift+F10 使用焦点触发元素的下边缘定位。`afterContent` 将数据操作放在这些行之后;普通操作排在之前。 + +----- + + +## 理解实现 + +
+实现细节 — 点击展开 + +注册表将查询和回调生命周期与展示分离。共享覆盖层复用现有 Menu 原语,处理键盘移动、焦点返回、关闭、子菜单和视口定位。[注册表](src/client/registry.ts) 管理取消与排序;[目标和操作类型](src/client/types.ts) 定义公开字段。 + +
+ +----- + + +## 进一步探索 + +- [Slots](../ui-slots/README.zh.md) — 组件贡献。 +- [文件操作](../ui-file-actions/README.zh.md) — 原始内容传输。 +- [本地打开](../ui-open-in-app/README.zh.md) — 内置资源操作。 + +----- + + +## 模型体验 + +无,因为弹出菜单展示与注册不会构造模型输入。 + +#### KV 缓存影响 + +无;贡献操作所属功能负责后续可能产生的模型交互。 + +## 已知限制与延后工作 + + + +- 内置渲染器是共享 Web Menu,Desktop 也使用它;未提供原生菜单渲染器。 +- 仅有组件形式的旧操作不会出现在纯数据查询 API 中。 +- 菜单是窗口内的临时状态,不持久化到会话数据。 + + +### 开发备注 + +
+维护者工作背景 — 点击展开 + +无。 + +
diff --git a/packages/client/ui-context-menu/locale/de.json b/packages/client/ui-context-menu/locale/de.json new file mode 100644 index 0000000000..4b83ec446a --- /dev/null +++ b/packages/client/ui-context-menu/locale/de.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Kontextmenüs" + } +} diff --git a/packages/client/ui-context-menu/locale/en.json b/packages/client/ui-context-menu/locale/en.json new file mode 100644 index 0000000000..edcf35ac5e --- /dev/null +++ b/packages/client/ui-context-menu/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Context menus" + } +} diff --git a/packages/client/ui-context-menu/locale/es.json b/packages/client/ui-context-menu/locale/es.json new file mode 100644 index 0000000000..dda4ab5e04 --- /dev/null +++ b/packages/client/ui-context-menu/locale/es.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Menús contextuales" + } +} diff --git a/packages/client/ui-context-menu/locale/fr.json b/packages/client/ui-context-menu/locale/fr.json new file mode 100644 index 0000000000..6e41b74ead --- /dev/null +++ b/packages/client/ui-context-menu/locale/fr.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Menus contextuels" + } +} diff --git a/packages/client/ui-context-menu/locale/ja.json b/packages/client/ui-context-menu/locale/ja.json new file mode 100644 index 0000000000..7b98bda82b --- /dev/null +++ b/packages/client/ui-context-menu/locale/ja.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "コンテキストメニュー" + } +} diff --git a/packages/client/ui-context-menu/locale/pt.json b/packages/client/ui-context-menu/locale/pt.json new file mode 100644 index 0000000000..4a2c18368a --- /dev/null +++ b/packages/client/ui-context-menu/locale/pt.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Menus de contexto" + } +} diff --git a/packages/client/ui-context-menu/locale/ru.json b/packages/client/ui-context-menu/locale/ru.json new file mode 100644 index 0000000000..33b3ad67a4 --- /dev/null +++ b/packages/client/ui-context-menu/locale/ru.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Контекстные меню" + } +} diff --git a/packages/client/ui-context-menu/locale/zh.json b/packages/client/ui-context-menu/locale/zh.json new file mode 100644 index 0000000000..974fc94de9 --- /dev/null +++ b/packages/client/ui-context-menu/locale/zh.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "右键菜单" + } +} diff --git a/packages/client/ui-context-menu/package.json b/packages/client/ui-context-menu/package.json new file mode 100644 index 0000000000..6aacc8717f --- /dev/null +++ b/packages/client/ui-context-menu/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-context-menu", + "description": "Typed, plugin-extensible context menus with captured targets and shared Web presentation", + "version": "0.2.2-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-context-menu" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-shortcuts" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-context-menu/src/client/View.tsx b/packages/client/ui-context-menu/src/client/View.tsx new file mode 100644 index 0000000000..58a9808f3a --- /dev/null +++ b/packages/client/ui-context-menu/src/client/View.tsx @@ -0,0 +1,60 @@ +/** Shared Web renderer for Client menu contributions. */ +import { Menu, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsStore, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { Shortcuts, ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' +import type { createMenuStore } from './store.ts' +import type { MenuActionView } from './types.ts' + +/** + * @param actions - plain presentation. + * @param catalog - effective user shortcuts. + * @param renderSlot - owner-authorized keyed icon renderer. + * @returns Existing Menu entries. + */ +export function menuEntries( + actions: readonly MenuActionView[], catalog: readonly ShortcutCatalogEntry[], + renderSlot: PropsRenderSlots<'contextMenu.icon'>['renderSlot'], +): MenuEntry[] { + return actions.flatMap((action) => { + const shortcut = catalog.find(entry => entry.id === action.commandId) + const item: MenuEntry = { id: action.id, label: action.label, + ...(shortcut === undefined ? {} : { shortcut }), + ...(action.disabled === undefined ? {} : { disabled: action.disabled }), + ...(action.danger === undefined ? {} : { danger: action.danger }), + ...(action.icon === undefined ? {} : { + icon: renderSlot('contextMenu.icon', { icon: action.icon }, { entryKey: action.icon.key }), + }), + ...(action.children === undefined ? {} : { submenu: menuEntries(action.children, catalog, renderSlot) }), + } + return action.separatorBefore ? [{ type: 'separator', id: `${action.id}:separator` }, item] : [item] + }) +} + +/** Shared overlay callbacks. */ +export interface MenuOverlayInjected { + hooks: { catalog: Shortcuts['catalog'] } + close: () => void + returnFocus?: () => HTMLElement | null + select: (id: string) => void +} +type Props = PropsStore> + & PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace + +/** @param props - registered state, target-specific legacy rows, and actions. @returns one menu and persistent feedback. */ +export function MenuOverlay({ useStore, actions, renderSlot, close, select, returnFocus, useCatalog }: Props) { + const catalog = useCatalog(value => value) + const { target, point, items, notice, occurrence, closing, replacing } = useStore(s => s) + return <> + new DOMRect(point.x, point.y, 0, 0)} + items={menuEntries(items.filter(item => !item.afterContent), catalog, renderSlot)} + footer={menuEntries(items.filter(item => item.afterContent), catalog, renderSlot)} onClose={close} onSelect={select}> + {target !== null && renderSlot('contextMenu.content', { target, dismiss: close }, { entryKey: target.kind })} + + {notice !== null && { actions.notify(null) }} />} + +} diff --git a/packages/client/ui-context-menu/src/client/index.ts b/packages/client/ui-context-menu/src/client/index.ts new file mode 100644 index 0000000000..ac4fc288be --- /dev/null +++ b/packages/client/ui-context-menu/src/client/index.ts @@ -0,0 +1,155 @@ +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +/** Window-local menu capability and its Web overlay. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import z from '@deepseek-ai/schemastery' +import { MENU_TRANSITION_MS } from '@deepseek-ai/dsh-client-ui-primitives' +import { menuTargetKey } from './target-key.ts' +import { MenuRegistry } from './registry.ts' +import { createMenuStore } from './store.ts' +import { dictionaries } from './locales.ts' +import { MenuOverlay } from './View.tsx' +import type { ContextMenus, MenuResolution, ContextMenuTarget, ContextMenuIcon, MenuPoint, UseContextMenu } from './types.ts' +import { createContextMenuBindings } from './trigger.ts' + +export type { ContextMenus, ContextMenuTarget, ContextMenuTargetMap, MenuPoint, MenuAction, MenuActionView, MenuContribution, MenuResolution } from './types.ts' +export type { ContextMenuBindings, ContextMenuTriggerProps, UseContextMenu } from './types.ts' +export type { ContextMenuIcon } from './types.ts' +export { createMenuStore } from './store.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { /** Extensible context menus for explicit clicked objects. */ contextMenus: ContextMenus } +} +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Menu bindings for explicit objects, available in every Slot scope. */ + useContextMenu: UseContextMenu + } + interface SlotMap { + /** Business-owned glyph or image selected by the action's icon key. */ + 'contextMenu.icon': { kind: 'keyed'; scope: 'root'; inline: true; owner: { icon: ContextMenuIcon } } + /** Optional React rows for existing extension positions; data contributions need no renderer. */ + 'contextMenu.content': { kind: 'keyed'; scope: 'root'; owner: { target: ContextMenuTarget; dismiss: () => void } } + } + interface LocaleNamespaceMap { /** Menu operation feedback. */ contextMenu: 'failed' } +} + +/** Maximum time to prepare each contribution before omitting its pending actions. */ +export interface Config { resolutionTimeoutMs?: number } +/** Validated Client menu preparation budget. */ +export const Config: z = z.object({ resolutionTimeoutMs: z.natural().min(1).default(1000) }) + +/** Required overlay and localization services. */ +export const inject = ['slots', 'locale', 'shortcuts'] +/** @param ctx - plugin-owned Client context. @param config - validated preparation budget. */ +export function apply(ctx: Context, config: Config = Config({})): void { + ctx.effect(() => ctx.locale.register('contextMenu', dictionaries), 'context menu: locale') + const t = ctx.locale.bind('contextMenu') + const handle = createMenuStore() + const instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + const targetSource = createSnapshotStore(null) + let sequence = 0 + let active: { resolution: MenuResolution; unsubscribe: () => void; target: ContextMenuTarget; point: MenuPoint } | undefined + let closeTimer: ReturnType | undefined + let openingTimer: ReturnType | undefined + let finishOpening: (() => void) | undefined + let returnFocus: HTMLElement | null = null + let disposed = false + const notify: ContextMenus['notify'] = (text, success = false, action) => { + if (disposed) return + const id = ++sequence + instance.actions.notify({ id, text, success, ...action === undefined ? {} : { action: { + label: action.label, + onClick: () => { + if (disposed || instance.getSnapshot().notice?.id !== id) return + instance.actions.notify(null) + try { void Promise.resolve(action.run()).catch(() => { notify(action.failureLabel) }) } + catch (_error) { notify(action.failureLabel) } + }, + } } }) + } + const registry = new MenuRegistry((message) => { notify(message ?? t('failed')) }, config.resolutionTimeoutMs) + const cancelOpening = (): void => { + clearTimeout(openingTimer); finishOpening?.(); finishOpening = undefined + active?.unsubscribe(); active?.resolution.dispose(); active = undefined + targetSource.set(null) + } + const dismiss = (replacing: boolean): void => { + cancelOpening() + clearTimeout(closeTimer) + if (instance.getSnapshot().target === null) return + instance.actions.exit(replacing) + closeTimer = setTimeout(() => { instance.actions.close() }, replacing ? MENU_TRANSITION_MS.replace : MENU_TRANSITION_MS.dismiss) + } + const close = (): void => { dismiss(false) } + const contextMenus: ContextMenus = { + active: targetSource, + register: input => registry.register(input), resolve: target => registry.resolve(target), notify, close, + open: (target, point) => { + const previous = active + if (previous !== undefined + && Math.hypot(previous.point.x - point.x, previous.point.y - point.y) <= 4 + && menuTargetKey(previous.target) === menuTargetKey(target)) { close(); return } + const focused = document.activeElement + if (!(focused instanceof Element) || focused.closest('[data-context-menu]') === null) { + returnFocus = focused instanceof HTMLElement ? focused : null + } + dismiss(true) + const resolution = registry.resolve(target) + targetSource.set(target) + let displayed = false + const publish = (): void => { if (displayed) instance.actions.items(resolution.source.getSnapshot()) } + const occurrence = { resolution, unsubscribe: resolution.source.subscribe(publish), target, point } + active = occurrence + const delay = new Promise((resolve) => { + finishOpening = resolve; openingTimer = setTimeout(resolve, MENU_TRANSITION_MS.prepare) + }) + void Promise.all([resolution.ready, delay]).then(() => { + if (active !== occurrence) return + clearTimeout(closeTimer) + instance.actions.open(target, point) + displayed = true + publish() + }) + }, + } + ctx.provide('contextMenus', contextMenus) + const bindings = createContextMenuBindings((target, point) => { contextMenus.open(target, point) }, () => !disposed) + ctx.slots.provideRoot({ props: { useContextMenu: () => bindings } }) + ctx.effect(() => { + const outside = (event: PointerEvent): void => { + if (event.button === 2 || (event.target instanceof Element && event.target.closest('[role="menu"], [data-context-menu-trigger]'))) return + close() + } + const context = (event: MouseEvent): void => { if (!event.defaultPrevented) close() } + const escape = (event: KeyboardEvent): void => { + if (active !== undefined && instance.getSnapshot().target === null && event.key === 'Escape') { + event.preventDefault(); event.stopImmediatePropagation(); close() + } + } + document.addEventListener('pointerdown', outside) + document.addEventListener('contextmenu', context) + window.addEventListener('keydown', escape, true) + return () => { + disposed = true; cancelOpening(); clearTimeout(closeTimer); registry.dispose(); instance.actions.close() + document.removeEventListener('pointerdown', outside) + document.removeEventListener('contextmenu', context) + window.removeEventListener('keydown', escape, true) + } + }, 'context menu: lifetime') + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'context-menu', store, + children: { + 'contextMenu.content': { kind: 'keyed', scope: 'root' }, + 'contextMenu.icon': { kind: 'keyed', scope: 'root', inline: true }, + }, + inject: () => ({ hooks: { catalog: ctx.shortcuts.catalog }, close, returnFocus: () => returnFocus, select: (id: string) => { + const selected = active?.resolution + active?.unsubscribe(); active = undefined + targetSource.set(null); dismiss(false) + selected?.select(id); selected?.dispose() } }), + }, MenuOverlay)) +} diff --git a/packages/client/ui-context-menu/src/client/locales.ts b/packages/client/ui-context-menu/src/client/locales.ts new file mode 100644 index 0000000000..d8b33a422e --- /dev/null +++ b/packages/client/ui-context-menu/src/client/locales.ts @@ -0,0 +1,11 @@ +/** Context-menu operation feedback, separate from plugin display metadata. */ +export const dictionaries = { + zh: { failed: '操作失败,请重试' }, + en: { failed: 'Could not complete the action. Try again.' }, + ja: { failed: '操作に失敗しました。再試行してください。' }, + es: { failed: 'No se pudo completar la acción. Inténtelo de nuevo.' }, + fr: { failed: 'Impossible de terminer l’action. Réessayez.' }, + de: { failed: 'Die Aktion konnte nicht abgeschlossen werden. Versuchen Sie es erneut.' }, + ru: { failed: 'Не удалось выполнить действие. Повторите попытку.' }, + pt: { failed: 'Não foi possível concluir a ação. Tente novamente.' }, +} satisfies Record diff --git a/packages/client/ui-context-menu/src/client/registry.ts b/packages/client/ui-context-menu/src/client/registry.ts new file mode 100644 index 0000000000..3725bb9fed --- /dev/null +++ b/packages/client/ui-context-menu/src/client/registry.ts @@ -0,0 +1,160 @@ +/** Ordered menu resolution with cancellation and contribution-owned action lifetimes. */ +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { MenuAction, MenuActionView, MenuContribution, MenuResolution, ContextMenuTarget, ContextMenuTargetMap } from './types.ts' + +interface Provider { + readonly id: string + readonly order: number + readonly kind: keyof ContextMenuTargetMap + readonly lifetime: AbortController + observe(target: ContextMenuTarget): readonly ObservableSnapshot[] + resolve(target: ContextMenuTarget, signal: AbortSignal): readonly MenuAction[] | Promise +} + +/** React-free registry; each occurrence captures the providers registered at opening. */ +export class MenuRegistry { + private readonly providers = new Map() + private readonly occurrences = new Set<() => void>() + private disposed = false + constructor(private readonly failed: (message?: string) => void, private readonly timeoutMs?: number) {} + + /** + * Register actions for subsequent openings. + * @param input - provider definition. + * @returns removal of this contribution and its pending resolutions. + */ + register(input: MenuContribution): () => void { + if (this.disposed) throw new Error('Menu registry is disposed') + const key = `${encodeURIComponent(input.target)}/${encodeURIComponent(input.id)}` + if (this.providers.has(key)) throw new Error(`Duplicate menu contribution: ${key}`) + const provider: Provider = { + id: input.id, kind: input.target, order: input.order, lifetime: new AbortController(), + observe: target => input.observe?.(target.value as ContextMenuTargetMap[K]) ?? [], + resolve: (target, signal) => input.resolve(target.value as ContextMenuTargetMap[K], signal), + } + this.providers.set(key, provider) + return () => { + if (provider.lifetime.signal.aborted) return + this.providers.delete(key) + provider.lifetime.abort() + } + } + + /** + * Resolve an independently owned occurrence. + * @param target - captured clicked identity. + * @returns presentation, selection, and disposal. + */ + resolve(target: ContextMenuTarget): MenuResolution { + if (this.disposed) throw new Error('Menu registry is disposed') + const lifetime = new AbortController() + const source = createSnapshotStore([]) + const resolved = new Map() + const callbacks = new Map() + const providers = [...this.providers.values()].filter(p => p.kind === target.kind) + .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)) + const refresh = (): void => { + source.set(providers.flatMap(provider => + provider.lifetime.signal.aborted ? [] : resolved.get(provider) ?? [])) + } + const cleanup: (() => void)[] = [] + const dispose = (): void => { + if (lifetime.signal.aborted) return + lifetime.abort() + for (const detach of cleanup) detach() + callbacks.clear() + this.occurrences.delete(dispose) + } + this.occurrences.add(dispose) + const pending: Promise[] = [] + for (const provider of providers) { + let preparation: AbortController | undefined + let actionIds: string[] = [] + let preparing: Promise | undefined + const clear = (): void => { + for (const id of actionIds) callbacks.delete(id) + actionIds = [] + resolved.delete(provider) + refresh() + } + const prepare = (): void => { + preparation?.abort() + const deadline = new AbortController() + preparation = deadline + const signal = AbortSignal.any([lifetime.signal, provider.lifetime.signal, deadline.signal]) + const accept = (actions: readonly MenuAction[]): void => { + if (signal.aborted) return + const next = new Map() + const project = (action: MenuAction, parent: string): MenuActionView => { + const id = `${parent}/${encodeURIComponent(action.id)}` + if (next.has(id)) throw new Error(`Duplicate menu action: ${id}`) + next.set(id, action) + const { run: _run, failureLabel: _failureLabel, children, ...view } = action + return { ...view, id, ...(children === undefined ? {} : { children: children.map(child => project(child, id)) }) } + } + const views = actions.map(action => project(action, encodeURIComponent(provider.id))) + for (const id of actionIds) callbacks.delete(id) + for (const [id, action] of next) callbacks.set(id, { provider, action }) + actionIds = [...next.keys()] + resolved.set(provider, views) + refresh() + } + preparing = undefined + try { + const result = provider.resolve(target, signal) + if (result instanceof Promise) clear() + preparing = result instanceof Promise ? new Promise((settle) => { + const timer = this.timeoutMs === undefined ? undefined : setTimeout(() => { + deadline.abort(); this.failed() + }, this.timeoutMs) + const finish = (): void => { clearTimeout(timer); signal.removeEventListener('abort', finish); settle() } + signal.addEventListener('abort', finish, { once: true }) + void result.then(accept).catch(() => { if (!signal.aborted) this.failed() }).finally(finish) + }) : undefined + if (!(result instanceof Promise)) accept(result) + } catch (_error) { if (!signal.aborted) { clear(); this.failed() } } + } + const subscriptions: (() => void)[] = [] + const withdraw = (): void => { + preparation?.abort() + for (const detach of subscriptions.splice(0)) detach() + clear() + } + provider.lifetime.signal.addEventListener('abort', withdraw, { once: true }) + cleanup.push(() => { + provider.lifetime.signal.removeEventListener('abort', withdraw) + withdraw() + }) + try { + for (const input of provider.observe(target)) subscriptions.push(input.subscribe(prepare)) + prepare() + } catch (_error) { if (!lifetime.signal.aborted && !provider.lifetime.signal.aborted) this.failed() } + pending.push((async () => { + let current: Promise | undefined + do { current = preparing; await current } while (current !== preparing) + })()) + } + return { + source, dispose, ready: Promise.all(pending).then(() => {}), + select: (id) => { + const selected = callbacks.get(id) + if (lifetime.signal.aborted || selected === undefined || selected.provider.lifetime.signal.aborted + || selected.action.disabled || selected.action.children !== undefined) return + const run = selected.action.run + dispose() + try { void Promise.resolve(run?.()).catch(() => { this.failed(selected.action.failureLabel) }) } + catch (_error) { this.failed(selected.action.failureLabel) } + }, + } + } + + /** Cancel every occurrence and reject further registrations or resolutions. */ + dispose(): void { + if (this.disposed) return + this.disposed = true + for (const dispose of this.occurrences) dispose() + for (const provider of this.providers.values()) provider.lifetime.abort() + this.providers.clear() + } +} diff --git a/packages/client/ui-context-menu/src/client/store.ts b/packages/client/ui-context-menu/src/client/store.ts new file mode 100644 index 0000000000..8791201c91 --- /dev/null +++ b/packages/client/ui-context-menu/src/client/store.ts @@ -0,0 +1,41 @@ +/** Single-popup viewing state and feedback retained outside trigger components. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { MenuActionView, MenuPoint, ContextMenuTarget } from './types.ts' + +interface Notice { id: number; text: string; success: boolean; action?: { label: string; onClick: () => void } } +interface MenuState { + occurrence: number + closing: boolean + replacing: boolean + target: ContextMenuTarget | null + point: MenuPoint + items: readonly MenuActionView[] + notice: Notice | null +} +type MenuActions = { + open(draft: MenuState, target: ContextMenuTarget, point: MenuPoint): void + items(draft: MenuState, items: readonly MenuActionView[]): void + exit(draft: MenuState, replacing: boolean): void + close(draft: MenuState): void + notify(draft: MenuState, notice: Notice | null): void +} + +/** + * Create the root popup store. + * @returns the presentation and its mutation actions. + */ +export function createMenuStore(): EngineStoreHandle { + return defineStore({ + init: () => ({ occurrence: 0, closing: false, replacing: false, target: null, point: { x: 0, y: 0 }, items: [], notice: null }), + actions: { + open(draft, target, point) { + draft.occurrence++; draft.closing = false; draft.replacing = false + draft.target = target; draft.point = point; draft.items = [] + }, + items(draft, items) { draft.items = items }, + exit(draft, replacing) { draft.closing = true; draft.replacing = replacing }, + close(draft) { draft.target = null; draft.items = [] }, + notify(draft, notice) { draft.notice = notice }, + }, + }) +} diff --git a/packages/client/ui-context-menu/src/client/target-key.ts b/packages/client/ui-context-menu/src/client/target-key.ts new file mode 100644 index 0000000000..4304d2d617 --- /dev/null +++ b/packages/client/ui-context-menu/src/client/target-key.ts @@ -0,0 +1,14 @@ +/** Stable equality for the JSON-compatible target vocabulary, independent of object insertion order. */ +import type { ContextMenuTarget } from './types.ts' + +/** + * Compare captured JSON targets independently of object key insertion order. + * @param target - captured immutable target. + * @returns canonical JSON identity. + */ +export function menuTargetKey(target: ContextMenuTarget): string { + return JSON.stringify(target, (_key: string, value: unknown): unknown => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return value + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) + }) +} diff --git a/packages/client/ui-context-menu/src/client/trigger.ts b/packages/client/ui-context-menu/src/client/trigger.ts new file mode 100644 index 0000000000..04cd530e1a --- /dev/null +++ b/packages/client/ui-context-menu/src/client/trigger.ts @@ -0,0 +1,43 @@ +/** DOM gesture bindings owned by one menu plugin instance. */ +import type { SyntheticEvent } from 'react' +import type { ContextMenuTriggerProps, MenuPoint, ContextMenuTarget } from './types.ts' + +/** + * Create stable bindings for the plugin's injected hook. + * @param open - Open against the supplied captured target and viewport position. + * @param active - Whether the plugin still owns its service. + * @returns Ordinary trigger-prop factory; no React subscription or context is created. + */ +export function createContextMenuBindings( + open: (target: Target, point: MenuPoint) => void, active: () => boolean, +): { getTriggerProps: (target: Target | (() => Target) | undefined, handlers?: ContextMenuTriggerProps) => ContextMenuTriggerProps } { + return { + getTriggerProps(target, handlers = {}) { + if (target === undefined) return handlers + const show = (event: SyntheticEvent, x: number, y: number): void => { + if (event.defaultPrevented || !active()) return + const captured = typeof target === 'function' ? target() : target + event.preventDefault() + event.stopPropagation() + event.currentTarget.focus({ preventScroll: true }) + open(captured, { x, y }) + } + return { + ...handlers, + tabIndex: handlers.tabIndex ?? 0, + onContextMenu(event) { + handlers.onContextMenu?.(event) + const rect = event.clientX === 0 && event.clientY === 0 ? event.currentTarget.getBoundingClientRect() : undefined + show(event, rect?.left ?? event.clientX, rect?.bottom ?? event.clientY) + }, + onKeyDown(event) { + handlers.onKeyDown?.(event) + if (event.defaultPrevented || event.repeat || event.nativeEvent.isComposing || event.ctrlKey || event.altKey || event.metaKey + || !(event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey)) return + const rect = event.currentTarget.getBoundingClientRect() + show(event, rect.left, rect.bottom) + }, + } + }, + } +} diff --git a/packages/client/ui-context-menu/src/client/types.ts b/packages/client/ui-context-menu/src/client/types.ts new file mode 100644 index 0000000000..d32a788fb4 --- /dev/null +++ b/packages/client/ui-context-menu/src/client/types.ts @@ -0,0 +1,143 @@ +/** Client menu contributions and explicit gesture targets. */ +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { PaneId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { HTMLAttributes } from 'react' + +/** DOM handlers bound to an explicit object; existing handlers can prevent menu activation. */ +export type ContextMenuTriggerProps = Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'> + +/** Functions supplied by the globally injected useContextMenu hook. */ +export interface ContextMenuBindings { + /** + * Bind an object or deferred business target to one element. This is an ordinary function, safe in loops. + * @param target - Object captured when the gesture runs; undefined retains native behavior. + * @param handlers - Existing handlers run first and can cancel with preventDefault(). + * @returns Combined pointer and keyboard handlers plus a keyboard focus stop. + */ + getTriggerProps: ( + target: ContextMenuTarget | (() => ContextMenuTarget) | undefined, handlers?: ContextMenuTriggerProps, + ) => ContextMenuTriggerProps +} + +/** Read menu gesture bindings owned by the current menu plugin lifetime. */ +export type UseContextMenu = () => ContextMenuBindings + +/** External plugins extend this map with their own target families. */ +export interface ContextMenuTargetMap { + session: { readonly sessionId: SessionId; readonly displayTitle: string } + file: { readonly sessionId: SessionId; readonly path: string; readonly line?: number; readonly paneId?: PaneId } + directory: { readonly sessionId: SessionId; readonly path: string } + url: { readonly sessionId: SessionId; readonly url: string } + image: { + readonly sessionId?: SessionId + readonly url: string + readonly name?: string + readonly mediaType?: string + readonly path?: string + } + tab: { readonly sessionId: SessionId; readonly paneId: PaneId; readonly tab: TabRecord } +} + +/** Captured target; changing selection never changes an open menu's target. */ +export type ContextMenuTarget = { + [K in keyof ContextMenuTargetMap]: { readonly kind: K; readonly value: ContextMenuTargetMap[K] } +}[keyof ContextMenuTargetMap] +/** Pointer position in CSS pixels; keyboard gestures use the trigger's bottom edge. */ +export interface MenuPoint { readonly x: number; readonly y: number } +/** Keyed icon renderer and optional image URL captured with the action. */ +export interface ContextMenuIcon { + readonly key: string + readonly src?: string +} +/** Client presentation fields; callbacks stay in the registry. */ +export interface MenuActionView { + readonly id: string + readonly label: string + /** Command identity for live shortcut display only; selection still invokes this action’s run callback. */ + readonly commandId?: ShortcutCommandId + readonly disabled?: boolean + readonly icon?: ContextMenuIcon + readonly danger?: boolean + readonly separatorBefore?: boolean + /** Render after legacy component contributions in the shared Web popup. */ + readonly afterContent?: boolean + readonly children?: readonly MenuActionView[] +} +/** A contribution can decline, return actions immediately, or resolve metadata asynchronously. */ +export interface MenuAction extends Omit { + readonly children?: readonly MenuAction[] + /** Localized operation and recovery guidance when execution rejects. */ + readonly failureLabel?: string + /** Execute against the captured target. Reject for a visible failure; cancellation resolves normally. */ + readonly run?: () => void | Promise +} +/** A namespaced contribution, ordered within its target family. */ +export interface MenuContribution { + readonly id: string + readonly target: K + readonly order: number + /** + * Reactive inputs that re-resolve actions for the same captured target. + * @param target - clicked object. + * @returns sources owned by the contributing feature; unsubscribed on dismissal or unload. + */ + observe?(target: ContextMenuTargetMap[K]): readonly ObservableSnapshot[] + /** + * @param target - clicked object. + * @param signal - aborted on dismissal, plugin unload, or a newer observed preparation. + * @returns available actions. + */ + resolve(target: ContextMenuTargetMap[K], signal: AbortSignal): readonly MenuAction[] | Promise +} +/** Independent menu occurrence used by custom renderers and embedded menus. */ +export interface MenuResolution { + /** Read a new immutable presentation after each provider settles, updates, or is withdrawn. */ + readonly source: ObservableSnapshot + /** Settles when all opening contributions have completed or been withdrawn. */ + readonly ready: Promise + /** + * Select once; disposed or removed contributions cannot execute. + * @param id - resolved action id. + */ + select(id: string): void + /** Cancel metadata queries and discard callbacks. */ + dispose(): void +} +/** Client capability used equally by built-in and external plugins. */ +export interface ContextMenus { + /** Captured target of the shared popup; null after dismissal. */ + readonly active: ObservableSnapshot + /** + * Register for subsequent openings; unloading withdraws actions and aborts their queries. Duplicate ids within a target fail. + * @param contribution - target provider. + * @returns unregister. + */ + register(contribution: MenuContribution): () => void + /** + * Open the single shared popup. + * @param target - captured object. + * @param point - viewport position. + */ + open(target: ContextMenuTarget, point: MenuPoint): void + /** + * Resolve an embedded menu without opening the shared popup. + * @param target - captured object. + * @returns owned occurrence. + */ + resolve(target: ContextMenuTarget): MenuResolution + /** Close the shared popup and cancel its metadata queries. */ + close(): void + /** + * Show localized feedback outside the originating surface. + * @param text - localized message. + * @param success - successful action. + * @param action - optional text action retained by this notice; rejects with localized failure feedback. + */ + notify(text: string, success?: boolean, action?: { + readonly label: string + readonly run: () => void | Promise + readonly failureLabel: string + }): void +} diff --git a/packages/client/ui-context-menu/src/index.ts b/packages/client/ui-context-menu/src/index.ts new file mode 100644 index 0000000000..77258040c7 --- /dev/null +++ b/packages/client/ui-context-menu/src/index.ts @@ -0,0 +1,4 @@ +/** Host Loader entry; menus belong to the Client. */ + +/** Register the browser plugin without Host behavior. */ +export function apply(): void {} diff --git a/packages/client/ui-context-menu/tests/overlay.client.spec.tsx b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx new file mode 100644 index 0000000000..54194a811b --- /dev/null +++ b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { bindSnapshotSelector, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' +import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts' +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { createMenuStore } from '../src/client/store.ts' +import { MenuOverlay } from '../src/client/View.tsx' +import { apply, inject } from '../src/client/index.ts' +import type { ContextMenuTarget } from '../src/client/types.ts' + +afterEach(() => { cleanup(); vi.useRealTimers() }) + +it('renders legacy Session actions before data contributions and restores More focus after dismissal', async () => { + const store = createMenuStore().create(), run = vi.fn() + const close = () => { store.actions.close() } + const useStore = bindSnapshotSelector(store) + const view = render(<> + + select([])} useStore={useStore} actions={store.actions} close={close} select={run} + renderSlot={(_key, owner: object) => { + const { target, dismiss } = owner as { target: ContextMenuTarget; dismiss: () => void } + return { + if (target.kind === 'session') run(target.value.sessionId) + dismiss() + }}>Export + }} /> + ) + const trigger = view.getByRole('button', { name: 'More' }) + trigger.focus(); fireEvent.click(trigger) + await waitFor(() =>{ expect(view.getByRole('menuitem', { name: 'Export' })).toBe(document.activeElement) }) + fireEvent.click(view.getByRole('menuitem', { name: 'Export' })) + await waitFor(() =>{ expect(document.activeElement).toBe(trigger) }) + expect(run).toHaveBeenCalledExactlyOnceWith('clicked') + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(trigger) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(view.queryByRole('menu')).toBeNull() +}) + +it('renders disabled, grouped and nested data actions and keeps feedback after menu dismissal', () => { + vi.useFakeTimers() + const store = createMenuStore().create(), select = vi.fn() + store.actions.open({ kind: 'session', value: { sessionId: SessionId('clicked'), displayTitle: 'Clicked' } }, { x: 20, y: 30 }) + store.actions.items([ + { id: 'plain', label: 'Open' }, + { id: 'blocked', label: 'Unavailable', disabled: true, danger: true, separatorBefore: true }, + { id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original' }, { id: 'other', label: 'Other', separatorBefore: true }] }, + { id: 'footer', label: 'Folder', afterContent: true }, + ]) + const view = render( select([])} useStore={bindSnapshotSelector(store)} actions={store.actions} + close={() => { store.actions.close() }} select={select} renderSlot={() => null} />) + expect(view.getByRole('menuitem', { name: 'Unavailable' }).hasAttribute('disabled')).toBe(true) + fireEvent.click(view.getByRole('menuitem', { name: 'Open' })) + expect(select).toHaveBeenCalledWith('plain') + fireEvent.click(view.getByRole('menuitem', { name: 'Export' })) + expect(view.getAllByRole('separator')).toHaveLength(2) + fireEvent.click(view.getByRole('menuitem', { name: 'Original' })) + expect(select).toHaveBeenCalledWith('child') + act(() => { store.actions.exit(true) }) + expect(document.querySelector('[data-menu-phase="replace"]')).not.toBeNull() + act(() => { store.actions.exit(false) }) + expect(document.querySelector('[data-menu-phase="dismiss"]')).not.toBeNull() + const reveal = vi.fn() + act(() => { store.actions.close(); store.actions.notify({ id: 1, text: 'Saved', success: true, + action: { label: 'Show in Finder', onClick: reveal } }) }) + expect(view.queryByRole('menu')).toBeNull() + expect(view.getByRole('alert').textContent).toBe('SavedShow in Finder') + fireEvent.click(view.getByRole('button', { name: 'Show in Finder' })) + expect(reveal).toHaveBeenCalledOnce() + act(() => { store.actions.notify({ id: 2, text: 'Copy failed', success: false }) }) + expect(view.getByRole('alert').textContent).toBe('Copy failed') + expect(view.queryByRole('button', { name: 'Show in Finder' })).toBeNull() + act(() => { vi.advanceTimersByTime(4000) }) + expect(view.queryByRole('alert')).toBeNull() +}) + +function PluginIcon({ icon }: PropsRuntime<'contextMenu.icon'>) { + return +} + +it('dispatches captured icon keys in rows, submenus and footers and retires renderer registrations on unload', async () => { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, ({ renderSlot }) => + <>{renderSlot('shell.overlay', {})}) + const menuPlugin = await runtime.mount({ inject, apply }) + const run = vi.fn() + const contribution = await runtime.mount({ inject: ['contextMenus'], apply(ctx) { + ctx.effect(() => ctx.contextMenus.register({ id: 'icons', target: 'session', order: 0, resolve: () => [ + { id: 'row', label: 'Open', icon: { key: 'example.icon', src: '/captured.png' }, run }, + { id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original', icon: { key: 'example.icon' } }] }, + { id: 'footer', label: 'Footer', afterContent: true, icon: { key: 'example.icon' } }, + ] })) + } }) + const icons = await runtime.mount({ inject: ['slots'], apply(ctx) { + ctx.slots.inject('contextMenu.icon', function* () { + yield ctx.slots.register({ name: 'contextMenu.icon', key: 'example.icon' }, PluginIcon) + yield ctx.slots.register({ name: 'contextMenu.icon', key: 'example.unused' }, () => Unused icon) + }) + } }) + const view = runtime.renderRoot() + const open = () => { runtime.ctx.contextMenus.open({ kind: 'session', value: { + sessionId: SessionId('icons'), displayTitle: 'Icons', + } }, { x: 20, y: 30 }) } + act(open) + await waitFor(() => { expect(view.getAllByTestId('plugin-icon')).toHaveLength(2) }) + expect(view.getByRole('menuitem', { name: 'Open' }).querySelector('img')?.getAttribute('src')).toBe('/captured.png') + expect(view.queryByText('Unused icon')).toBeNull() + fireEvent.click(view.getByRole('menuitem', { name: 'Export' })) + expect(view.getByRole('menuitem', { name: 'Original' }).querySelector('img')).not.toBeNull() + await icons.dispose() + expect(view.queryAllByTestId('plugin-icon')).toHaveLength(0) + fireEvent.click(view.getByRole('menuitem', { name: 'Open' })) + expect(run).toHaveBeenCalledOnce() + await contribution.dispose() + await menuPlugin.dispose() + expect(runtime.slots.spec('contextMenu.icon')).toBeUndefined() +}) + +it('tracks configured shortcuts for nested and footer actions without invoking command dispatch', () => { + const shortcuts = new ShortcutRegistry('desktop', 'macos') + const commandId = 'session.rename' as ShortcutCommandId + const resolveCommand = vi.fn(() => ({ status: 'pass' as const })) + const remove = shortcuts.register({ id: commandId, label: () => 'Rename', aliases: [], + defaults: { 'desktop:macos': { code: 'KeyG', modifiers: ['primary', 'alt'] } }, + regions: ['page'], modals: [], resolve: resolveCommand }) + const store = createMenuStore().create() + store.actions.open({ kind: 'session', value: { sessionId: SessionId('captured'), displayTitle: 'Captured' } }, { x: 30, y: 40 }) + store.actions.items([ + { id: 'rename', label: 'Rename', commandId }, + { id: 'missing', label: 'Unknown command', commandId: 'absent' as ShortcutCommandId }, + { id: 'parent', label: 'Submenu', children: [{ id: 'nested', label: 'Nested', commandId }] }, + { id: 'footer', label: 'Footer', commandId, afterContent: true }, + ]) + const select = vi.fn() + const view = render( null} />) + const item = view.getByRole('menuitem', { name: 'Rename' }) + expect(item.getAttribute('aria-keyshortcuts')).toBe('Alt+Meta+G') + expect(view.getByRole('menuitem', { name: 'Unknown command' }).getAttribute('aria-keyshortcuts')).toBeNull() + act(() => { shortcuts.configure({ ...shortcuts.config.getSnapshot(), revision: 'custom' as never, + document: { schemaVersion: 2, profiles: { 'desktop:macos': { 'session.rename': { code: 'KeyR', modifiers: ['primary', 'shift'] } } } } }) }) + expect(item.getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+R') + expect(view.getByRole('menuitem', { name: 'Footer' }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+R') + fireEvent.click(view.getByRole('menuitem', { name: 'Submenu' })) + expect(view.getByRole('menuitem', { name: 'Nested' }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+R') + act(() => { shortcuts.configure({ ...shortcuts.config.getSnapshot(), revision: 'unbound' as never, + document: { schemaVersion: 2, profiles: { 'desktop:macos': { 'session.rename': null } } } }) }) + expect(item.getAttribute('aria-keyshortcuts')).toBeNull() + expect(item.querySelector('kbd')).toBeNull() + act(remove) + expect(item.getAttribute('aria-keyshortcuts')).toBeNull() + fireEvent.click(item) + expect(select).toHaveBeenCalledWith('rename') + expect(resolveCommand).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-context-menu/tests/plugin.client.spec.ts b/packages/client/ui-context-menu/tests/plugin.client.spec.ts new file mode 100644 index 0000000000..afd3d97350 --- /dev/null +++ b/packages/client/ui-context-menu/tests/plugin.client.spec.ts @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import type { MenuAction } from '../src/client/types.ts' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { Context } from '@deepseek-ai/cordis' +import { afterEach, expect, it, vi } from 'vitest' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { MenuOverlayInjected } from '../src/client/View.tsx' +import { apply, inject } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' +import type { createMenuStore } from '../src/client/store.ts' + +afterEach(() => { vi.useRealTimers() }) + +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { pluginDocument: { readonly id: string } } +} + +it('lets an external contribution open another menu and withdraws its actions when its fiber unloads', async () => { + const ctx = new Context() + ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + let overlay!: MenuOverlayInjected + let store!: ReturnType['create']> + ctx.provide('slots', { provideRoot: () => () => {}, + inject: (_name: string, register: Parameters[1]) => ctx.effect(register), + register: (entry: { inject: () => MenuOverlayInjected; store: ReturnType }) => { + overlay = entry.inject(); store = entry.store.create(); return () => {} + }, + } as never) + ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const menuFiber = ctx.plugin({ inject, apply }) + await menuFiber.await() + expect(hostApply).not.toThrow() + const run = vi.fn() + const contribution = ctx.plugin({ inject: ['contextMenus'], apply: (plugin: Context) => { + plugin.effect(() => plugin.contextMenus.register({ + target: 'pluginDocument', id: 'example.document', order: 0, + resolve: ({ id }) => [{ id: 'inspect', label: id, run: () => { + run(id) + plugin.contextMenus.open({ kind: 'pluginDocument', value: { id: 'next' } }, { x: 3, y: 4 }) + } }], + })) + } }) + await contribution.await() + try { + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'clicked' } }, { x: 1, y: 2 }) + await vi.waitFor(() => { expect(store.getSnapshot().items.length).toBeGreaterThan(0) }) + overlay.select('example.document/inspect') + expect(run).toHaveBeenCalledExactlyOnceWith('clicked') + expect(ctx.contextMenus.active.getSnapshot()).toEqual({ kind: 'pluginDocument', value: { id: 'next' } }) + const occurrence = ctx.contextMenus.resolve({ kind: 'pluginDocument', value: { id: 'retained' } }) + expect(occurrence.source.getSnapshot().map(action => action.label)).toEqual(['retained']) + await contribution.dispose() + expect(occurrence.source.getSnapshot()).toEqual([]) + occurrence.select('example.document/inspect') + expect(run).toHaveBeenCalledOnce() + occurrence.dispose() + ctx.contextMenus.notify('Saved', true) + expect(store.getSnapshot().notice).toMatchObject({ text: 'Saved', success: true }) + ctx.contextMenus.notify('Unavailable') + expect(store.getSnapshot().notice).toMatchObject({ text: 'Unavailable', success: false }) + const reveal = vi.fn(async () => {}) + ctx.contextMenus.notify('Saved', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' }) + const previousAction = store.getSnapshot().notice!.action! + expect(previousAction.label).toBe('Show in folder') + previousAction.onClick() + previousAction.onClick() + expect(reveal).toHaveBeenCalledOnce() + expect(store.getSnapshot().notice).toBeNull() + ctx.contextMenus.notify('Saved again', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' }) + const replaced = store.getSnapshot().notice!.action! + ctx.contextMenus.notify('New notice') + replaced.onClick() + expect(reveal).toHaveBeenCalledOnce() + for (const fail of [() => { throw new Error('denied') }, async () => { throw new Error('offline') }]) { + ctx.contextMenus.notify('Saved', true, { label: 'Show in folder', run: fail, failureLabel: 'Reveal failed' }) + store.getSnapshot().notice!.action!.onClick() + await vi.waitFor(() => { expect(store.getSnapshot().notice).toMatchObject({ text: 'Reveal failed', success: false }) }) + } + const removeFailure = ctx.contextMenus.register({ target: 'pluginDocument', id: 'failure', order: 0, + resolve: () => { throw new Error('Metadata unavailable') } }) + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'failed' } }, { x: 0, y: 0 }) + expect(store.getSnapshot().notice?.text).toBe('failed') + removeFailure() + const removeAction = ctx.contextMenus.register({ target: 'pluginDocument', id: 'action', order: 0, + resolve: () => [{ id: 'copy', label: 'Copy', failureLabel: 'Copy failed', run: () => { throw new Error('Denied') } }] }) + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'failed-action' } }, { x: 0, y: 0 }) + overlay.select('action/copy') + expect(store.getSnapshot().notice?.text).toBe('Copy failed') + removeAction() + } finally { await contribution.dispose(); await menuFiber.dispose() } +}) + +it('waits for metadata, toggles nearby repeated gestures, replaces occurrences, and cancels pending openings', async () => { + vi.useFakeTimers() + const ctx = new Context() + ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + let store!: ReturnType['create']> + ctx.provide('slots', { provideRoot: () => () => {}, + inject: (_name: string, register: Parameters[1]) => ctx.effect(register), + register: (entry: { store: ReturnType }) => { store = entry.store.create(); return () => {} }, + } as never) + ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const fiber = ctx.plugin({ inject, apply }) + await fiber.await() + let finish!: (actions: readonly MenuAction[]) => void + ctx.contextMenus.register({ target: 'pluginDocument', id: 'slow', order: 0, + resolve: () => new Promise((resolve) => { finish = resolve }) }) + const target = { kind: 'pluginDocument' as const, value: { id: 'file' } } + try { + ctx.contextMenus.open(target, { x: 20, y: 30 }) + await vi.advanceTimersByTimeAsync(100) + expect(store.getSnapshot().target).toBeNull() + finish([{ id: 'open', label: 'Open' }]) + await vi.advanceTimersByTimeAsync(0) + expect(store.getSnapshot().items.map(item => item.label)).toEqual(['Open']) + const first = store.getSnapshot().occurrence + ctx.contextMenus.open(target, { x: 22, y: 31 }) + expect(ctx.contextMenus.active.getSnapshot()).toBeNull() + expect(store.getSnapshot().closing).toBe(true) + await vi.advanceTimersByTimeAsync(80) + expect(store.getSnapshot().target).toBeNull() + ctx.contextMenus.open(target, { x: 80, y: 30 }) + finish([{ id: 'second', label: 'Second' }]) + await vi.advanceTimersByTimeAsync(40) + expect(store.getSnapshot().occurrence).toBe(first + 1) + ctx.contextMenus.open(target, { x: 180, y: 30 }) + expect(store.getSnapshot().replacing).toBe(true) + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) + finish([{ id: 'late', label: 'Late' }]) + await vi.advanceTimersByTimeAsync(100) + expect(store.getSnapshot().target).toBeNull() + ctx.contextMenus.open(target, { x: 20, y: 30 }) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + finish([{ id: 'escape', label: 'Escape' }]) + await vi.advanceTimersByTimeAsync(100) + expect(store.getSnapshot().target).toBeNull() + } finally { await fiber.dispose() } +}) + +it('keeps trigger focus through replacement and ignores non-dismissal events and late feedback', async () => { + vi.useFakeTimers() + const ctx = new Context() + ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + let overlay!: MenuOverlayInjected + ctx.provide('slots', { provideRoot: () => () => {}, + inject: (_name: string, register: Parameters[1]) => ctx.effect(register), + register: (entry: { inject: () => MenuOverlayInjected }) => { overlay = entry.inject(); return () => {} }, + } as never) + ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const fiber = ctx.plugin({ inject, apply }) + await fiber.await() + const notify = ctx.contextMenus.notify.bind(ctx.contextMenus) + ctx.contextMenus.register({ target: 'pluginDocument', id: 'plain', order: 0, resolve: () => [{ id: 'copy', label: 'Copy' }] }) + const trigger = document.createElement('button'), menu = document.createElement('button') + menu.setAttribute('data-context-menu', '') + menu.setAttribute('role', 'menu') + document.body.append(trigger, menu) + try { + trigger.focus() + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'first' } }, { x: 10, y: 10 }) + await vi.advanceTimersByTimeAsync(40) + expect(overlay.returnFocus?.()).toBe(trigger) + menu.focus() + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'second' } }, { x: 80, y: 80 }) + await vi.advanceTimersByTimeAsync(40) + expect(overlay.returnFocus?.()).toBe(trigger) + menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 2 })) + menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + expect(ctx.contextMenus.active.getSnapshot()).not.toBeNull() + document.dispatchEvent(new MouseEvent('contextmenu', { cancelable: true })) + expect(ctx.contextMenus.active.getSnapshot()).toBeNull() + const handled = new MouseEvent('contextmenu', { cancelable: true }); handled.preventDefault() + document.dispatchEvent(handled) + document.dispatchEvent(new Event('pointerdown')) + expect(ctx.contextMenus.active.getSnapshot()).toBeNull() + const active = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null) + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'third' } }, { x: 80, y: 80 }) + active.mockRestore() + expect(overlay.returnFocus?.()).toBeNull() + } finally { trigger.remove(); menu.remove(); await fiber.dispose() } + notify('late') +}) + +it('leaves unbound keyboard gestures native', async () => { + const ctx = new Context() + ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + ctx.provide('slots', { provideRoot: () => () => {}, inject: () => () => {} } as never) + ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const fiber = ctx.plugin({ inject, apply }) + await fiber.await() + try { + const event = new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, cancelable: true }) + document.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + } finally { await fiber.dispose() } +}) diff --git a/packages/client/ui-context-menu/tests/registry.client.spec.ts b/packages/client/ui-context-menu/tests/registry.client.spec.ts new file mode 100644 index 0000000000..c533f3b49d --- /dev/null +++ b/packages/client/ui-context-menu/tests/registry.client.spec.ts @@ -0,0 +1,218 @@ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { describe, expect, it, vi } from 'vitest' +import { MenuRegistry } from '../src/client/registry.ts' +import type { MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client' + +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { extension: { readonly id: string } } +} +const target = { kind: 'extension' as const, value: { id: 'clicked' } } + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +describe('menu contribution lifecycle', () => { + it('orders independently resolved contributions and executes the captured target once', async () => { + const failure = vi.fn(), run = vi.fn() + const registry = new MenuRegistry(failure) + const waiting = deferred() + registry.register({ id: 'later', target: 'extension', order: 20, resolve: () => waiting.promise }) + registry.register({ id: 'first', target: 'extension', order: 10, resolve: ({ id }) => [{ id: 'open', label: id, run: () => { run(id) } }] }) + const menu = registry.resolve(target) + expect(menu.source.getSnapshot().map(x => x.id)).toEqual(['first/open']) + waiting.resolve([{ id: 'open', label: 'Later' }]); await waiting.promise + expect(menu.source.getSnapshot().map(x => x.id)).toEqual(['first/open', 'later/open']) + expect(JSON.parse(JSON.stringify(menu.source.getSnapshot()))).toEqual(menu.source.getSnapshot()) + menu.select('first/open'); menu.select('first/open') + expect(run).toHaveBeenCalledExactlyOnceWith('clicked') + expect(failure).not.toHaveBeenCalled() + registry.dispose() + }) + + it.each(['menu', 'provider', 'registry'] as const)('aborts pending metadata when its %s retires', async (retire) => { + const failure = vi.fn(), run = vi.fn() + const registry = new MenuRegistry(failure) + const waiting = deferred() + let signal!: AbortSignal + const remove = registry.register({ id: 'slow', target: 'extension', order: 0, resolve: (_target, lifetime) => { signal = lifetime; return waiting.promise } }) + const menu = registry.resolve(target) + if (retire === 'menu') menu.dispose() + if (retire === 'provider') remove() + if (retire === 'registry') registry.dispose() + expect(signal.aborted).toBe(true) + waiting.resolve([{ id: 'late', label: 'Late', run }]); await waiting.promise + expect(menu.source.getSnapshot()).toEqual([]) + menu.select('slow/late') + expect(run).not.toHaveBeenCalled() + expect(failure).not.toHaveBeenCalled() + menu.dispose(); remove(); registry.dispose() + }) + + it('withdraws resolved actions on unload and takes new contributions on the next opening', () => { + const registry = new MenuRegistry(vi.fn()), run = vi.fn() + const remove = registry.register({ id: 'old', target: 'extension', order: 0, resolve: () => [{ id: 'a', label: 'A', run }] }) + const first = registry.resolve(target) + remove(); remove() + expect(first.source.getSnapshot()).toEqual([]) + first.select('old/a'); expect(run).not.toHaveBeenCalled() + registry.register({ id: 'new', target: 'extension', order: 0, resolve: () => [{ id: 'b', label: 'B' }] }) + expect(first.source.getSnapshot()).toEqual([]) + const next = registry.resolve(target) + expect(next.source.getSnapshot()[0]?.id).toBe('new/b') + first.dispose(); next.dispose(); registry.dispose() + expect(() => registry.resolve(target)).toThrow('disposed') + }) + + it('contains provider failure and rejects duplicate contributions without corrupting other actions', async () => { + const failed = vi.fn(), run = vi.fn(), registry = new MenuRegistry(failed) + const definition = { id: 'good', target: 'extension' as const, order: 0, resolve: () => [{ id: 'a', label: 'A', run }] } + registry.register(definition) + expect(() => registry.register(definition)).toThrow('Duplicate menu contribution') + registry.register({ id: 'bad', target: 'extension', order: 1, resolve: () => [{ id: 'a', label: 'A' }, { id: 'a', label: 'B' }] }) + registry.register({ id: 'rejected', target: 'extension', order: 2, resolve: () => Promise.reject(new Error('unavailable')) }) + const menu = registry.resolve(target) + await Promise.resolve(); await Promise.resolve() + expect(failed).toHaveBeenCalledTimes(2) + expect(menu.source.getSnapshot().map(x => x.id)).toEqual(['good/a']) + menu.select('bad/a'); expect(run).not.toHaveBeenCalled() + menu.select('good/a'); expect(run).toHaveBeenCalledOnce() + registry.dispose() + }) + + it('ignores disabled, submenu and unknown selections and reports an action rejection', async () => { + const failed = vi.fn(), run = vi.fn(), registry = new MenuRegistry(failed) + registry.register({ id: 'actions', target: 'extension', order: 0, resolve: () => [ + { id: 'disabled', label: 'Disabled', disabled: true, run }, + { id: 'parent', label: 'Parent', children: [{ id: 'child', label: 'Child', run: () => Promise.reject(new Error('failed')) }], run }, + ] }) + const menu = registry.resolve(target) + menu.select('missing'); menu.select('actions/disabled'); menu.select('actions/parent') + expect(run).not.toHaveBeenCalled() + menu.select('actions/parent/child'); await Promise.resolve(); await Promise.resolve() + expect(failed).toHaveBeenCalledOnce() + registry.dispose() + }) + + it('orders equal-priority providers by identity and contains synchronous action failures', () => { + const failed = vi.fn(), registry = new MenuRegistry(failed) + registry.register({ id: 'z', target: 'extension', order: 1, resolve: () => [{ id: 'none', label: 'No action' }] }) + registry.register({ id: 'a', target: 'extension', order: 1, resolve: () => [{ id: 'throw', label: 'Fail', + failureLabel: 'Could not copy. Try again.', run: () => { throw new Error('clipboard denied') } }] }) + const first = registry.resolve(target) + expect(first.source.getSnapshot().map(item => item.id)).toEqual(['a/throw', 'z/none']) + first.select('a/throw') + expect(failed).toHaveBeenCalledWith('Could not copy. Try again.') + const second = registry.resolve(target) + second.select('z/none') + registry.dispose() + expect(() => registry.register({ id: 'late', target: 'extension', order: 0, resolve: () => [] })).toThrow('disposed') + }) + + it('does not report cancelled metadata failures, including unload during resolution', async () => { + const failed = vi.fn(), registry = new MenuRegistry(failed) + let remove = () => {} + remove = registry.register({ id: 'self-removing', target: 'extension', order: 0, resolve: () => { + remove(); throw new Error('owner unloaded') + } }) + const waiting = deferred() + registry.register({ id: 'late-rejection', target: 'extension', order: 1, resolve: () => waiting.promise }) + const menu = registry.resolve(target) + menu.dispose() + waiting.reject(new Error('cancelled')) + await Promise.resolve(); await Promise.resolve() + expect(failed).not.toHaveBeenCalled() + registry.dispose() + }) +}) + +it('omits a provider that misses its deadline and ignores its late result', async () => { + vi.useFakeTimers() + const failed = vi.fn(), registry = new MenuRegistry(failed, 50) + const waiting = deferred() + let signal!: AbortSignal + registry.register({ id: 'slow', target: 'extension', order: 0, resolve: (_target, value) => { signal = value; return waiting.promise } }) + registry.register({ id: 'ready', target: 'extension', order: 1, resolve: () => [{ id: 'copy', label: 'Copy' }] }) + const menu = registry.resolve(target) + try { + await vi.advanceTimersByTimeAsync(50) + await menu.ready + expect(signal.aborted).toBe(true) + expect(menu.source.getSnapshot().map(item => item.label)).toEqual(['Copy']) + expect(failed).toHaveBeenCalledOnce() + waiting.resolve([{ id: 'late', label: 'Late' }]) + await waiting.promise + expect(menu.source.getSnapshot().map(item => item.label)).toEqual(['Copy']) + } finally { registry.dispose(); vi.useRealTimers() } +}) + +it('replaces observed actions and callbacks, cancels older preparation, and releases every subscription', async () => { + const input = createSnapshotStore(0) + const original = input.subscribe.bind(input) + const subscribe = vi.spyOn(input, 'subscribe') + const detach = vi.fn() + subscribe.mockImplementation((listener) => { + const off = original(listener) + return () => { detach(); off() } + }) + const failed = vi.fn(), registry = new MenuRegistry(failed), run = vi.fn() + const old = deferred(), current = deferred() + const signals: AbortSignal[] = [] + const resolve = vi.fn((_target: { id: string }, signal: AbortSignal) => { + signals.push(signal) + return input.getSnapshot() === 0 ? old.promise : current.promise + }) + const remove = registry.register({ id: 'live', target: 'extension', order: 0, observe: () => [input], resolve }) + const menu = registry.resolve(target) + try { + input.set(1) + expect(signals[0]?.aborted).toBe(true) + old.resolve([{ id: 'old', label: 'Old', run }]) + await old.promise + expect(menu.source.getSnapshot()).toEqual([]) + current.resolve([{ id: 'new', label: 'New', run }]) + await menu.ready + expect(menu.source.getSnapshot().map(item => item.id)).toEqual(['live/new']) + menu.select('live/old') + expect(run).not.toHaveBeenCalled() + remove() + expect(detach).toHaveBeenCalledOnce() + expect(menu.source.getSnapshot()).toEqual([]) + input.set(2) + expect(resolve).toHaveBeenCalledTimes(2) + expect(signals.every(signal => signal.aborted)).toBe(true) + expect(failed).not.toHaveBeenCalled() + } finally { menu.dispose(); registry.dispose() } + expect(detach).toHaveBeenCalledOnce() +}) + +it('withdraws invalid reactive updates and contains observation failure without retaining stale callbacks', () => { + const input = createSnapshotStore(false), failed = vi.fn(), run = vi.fn() + const registry = new MenuRegistry(failed) + registry.register({ id: 'live', target: 'extension', order: 0, observe: () => [input], resolve: () => { + if (input.getSnapshot()) throw new Error('No access') + return [{ id: 'run', label: 'Run', run }] + } }) + registry.register({ id: 'broken', target: 'extension', order: 1, observe: () => { throw new Error('Unavailable') }, resolve: () => [] }) + const menu = registry.resolve(target) + input.set(true) + expect(menu.source.getSnapshot()).toEqual([]) + menu.select('live/run') + expect(run).not.toHaveBeenCalled() + expect(failed).toHaveBeenCalledTimes(2) + menu.dispose(); registry.dispose() +}) + +it('does not report an observation failure after its provider unloads', () => { + const failed = vi.fn(), registry = new MenuRegistry(failed) + let remove = () => {} + remove = registry.register({ id: 'removed', target: 'extension', order: 0, + observe: () => { remove(); throw new Error('Unloaded') }, resolve: () => [] }) + const menu = registry.resolve(target) + expect(menu.source.getSnapshot()).toEqual([]) + expect(failed).not.toHaveBeenCalled() + menu.dispose(); registry.dispose() +}) diff --git a/packages/client/ui-context-menu/tests/target-key.client.spec.ts b/packages/client/ui-context-menu/tests/target-key.client.spec.ts new file mode 100644 index 0000000000..0f6830f205 --- /dev/null +++ b/packages/client/ui-context-menu/tests/target-key.client.spec.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { menuTargetKey } from '../src/client/target-key.ts' +import type { ContextMenuTarget } from '../src/client/types.ts' + +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { nested: { id: string; data: { a: number; b: readonly (string | null)[] } } } +} + +it('compares JSON targets independently of object key insertion order while preserving arrays', () => { + const first: ContextMenuTarget = { kind: 'nested', value: { id: 'a', data: { a: 1, b: ['x', null] } } } + const second: ContextMenuTarget = { value: { data: { b: ['x', null], a: 1 }, id: 'a' }, kind: 'nested' } + expect(menuTargetKey(first)).toBe(menuTargetKey(second)) + expect(menuTargetKey(first)).not.toBe(menuTargetKey({ kind: 'nested', value: { id: 'a', data: { a: 1, b: [null, 'x'] } } })) +}) diff --git a/packages/client/ui-context-menu/tests/trigger.client.spec.tsx b/packages/client/ui-context-menu/tests/trigger.client.spec.tsx new file mode 100644 index 0000000000..d83a906a3b --- /dev/null +++ b/packages/client/ui-context-menu/tests/trigger.client.spec.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { act, cleanup, createEvent, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { createPortal } from 'react-dom' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { apply, inject } from '../src/client/index.ts' +import { createContextMenuBindings } from '../src/client/trigger.ts' +import type { ContextMenuTriggerProps, ContextMenuTarget } from '../src/client/types.ts' + +afterEach(cleanup) +const target = (id: string): ContextMenuTarget => ({ kind: 'session', value: { sessionId: SessionId(id), displayTitle: id } }) + +it('binds targets in loops, captures deferred identity before focus changes it, and composes existing handlers', () => { + const open = vi.fn(), previous = vi.fn(), clicked = vi.fn() + let selected = 'first' + const { getTriggerProps } = createContextMenuBindings(open, () => true) + const view = render(<> + {['one', 'two'].map(id => )} + + ) + for (const id of ['one', 'two']) { + fireEvent.contextMenu(view.getByRole('button', { name: id }), { clientX: 12, clientY: 23 }) + expect(open).toHaveBeenLastCalledWith(target(id), { x: 12, y: 23 }) + } + expect(previous).toHaveBeenCalledTimes(2) + const button = view.getByRole('button', { name: 'Captured' }) + button.getBoundingClientRect = () => new DOMRect(30, 40, 50, 20) + const focus = vi.spyOn(button, 'focus') + fireEvent.keyDown(button, { key: 'F10', shiftKey: true }) + expect(open).toHaveBeenLastCalledWith(target('first'), { x: 30, y: 60 }) + expect(focus).toHaveBeenCalledWith({ preventScroll: true }) + expect(button.tabIndex).toBe(-1) + expect(clicked).not.toHaveBeenCalled() + fireEvent.contextMenu(button) + expect(open).toHaveBeenLastCalledWith(target('changed'), { x: 30, y: 60 }) +}) + +it('honors prevented and nested gestures, portals, repeats, composition, and missing targets', () => { + const open = vi.fn(), outer = vi.fn(), existing = vi.fn() + const bindings = createContextMenuBindings(open, () => true) + const parent = createContextMenuBindings(outer, () => true) + const inner = bindings.getTriggerProps(target('inner')) + const composed = bindings.getTriggerProps(target('second'), inner) + const view = render(
+ + + + {createPortal(, document.body)} +
) + const button = view.getByRole('button', { name: 'Inner' }) + fireEvent.contextMenu(button, { clientX: 1, clientY: 2 }) + fireEvent.keyDown(button, { key: 'ContextMenu' }) + expect(open).toHaveBeenCalledTimes(2) + expect(open).toHaveBeenLastCalledWith(target('inner'), { x: 0, y: 0 }) + fireEvent.contextMenu(view.getByRole('button', { name: 'Blocked' })) + fireEvent.keyDown(view.getByRole('button', { name: 'Blocked' }), { key: 'ContextMenu' }) + for (const extra of [{ repeat: true }, { isComposing: true }, { ctrlKey: true }, { altKey: true }, { metaKey: true }, { key: 'Enter' }]) { + fireEvent.keyDown(button, { key: 'ContextMenu', ...extra }) + } + const prevented = createEvent.contextMenu(button) + prevented.preventDefault(); fireEvent(button, prevented) + expect(open).toHaveBeenCalledTimes(2) + fireEvent.contextMenu(view.getByRole('button', { name: 'Portal' }), { clientX: 4, clientY: 5 }) + expect(open).toHaveBeenCalledTimes(3) + expect(outer).not.toHaveBeenCalled() + fireEvent.keyDown(view.getByRole('button', { name: 'Native' }), { key: 'a' }) + expect(existing).toHaveBeenCalledOnce() +}) + +it('injects the hook through the root Slot and withdraws both its prop and retained handlers on unload', async () => { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + let retained: ContextMenuTriggerProps = {} + let received = false + await runtime.root.declare({}, ({ useContextMenu }) => { + received = typeof useContextMenu === 'function' + if (!received) return null + retained = useContextMenu().getTriggerProps(target('injected')) + return + }) + const plugin = await runtime.mount({ inject, apply }) + const view = runtime.renderRoot() + expect(received).toBe(true) + act(() => { fireEvent.contextMenu(view.getByRole('button')) }) + expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(target('injected')) + const source = runtime.ctx.contextMenus.active + await plugin.dispose() + expect(received).toBe(false) + expect(source.getSnapshot()).toBeNull() + const detached = render() + expect(fireEvent.contextMenu(detached.getByRole('button'))).toBe(true) + expect(fireEvent.keyDown(detached.getByRole('button'), { key: 'ContextMenu' })).toBe(true) + expect(source.getSnapshot()).toBeNull() +}) diff --git a/packages/client/ui-context-menu/tsconfig.json b/packages/client/ui-context-menu/tsconfig.json new file mode 100644 index 0000000000..e12f61a4c8 --- /dev/null +++ b/packages/client/ui-context-menu/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../core/session" + }, + { + "path": "../locale/tsconfig.client.json" + }, + { + "path": "../store" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-dockkit" + }, + { + "path": "../ui-layout" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../shortcuts/tsconfig.client.json" + } + ] +} diff --git a/packages/client/ui-context-menu/tsdown.config.ts b/packages/client/ui-context-menu/tsdown.config.ts new file mode 100644 index 0000000000..7aa03cebc1 --- /dev/null +++ b/packages/client/ui-context-menu/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 353750e5e9..0f945d3708 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -24,8 +24,8 @@ en: 9b596b668979544a zh: 159f24dfc8715c92 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: - en: bb1244310f5c0382 - zh: 824b84077432b559 + en: 59cd63057dfb6eeb + zh: b2e2c86e810b7bb8 /deepseek-ai-dsh-client-ui-conversation/model-experience: en: e0a05b73ba3e4318 zh: dc85ab8433bb4456 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0633998db6..2002f10c1b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -184,6 +184,8 @@ The selector must be a pure function of the owner currency. Its non-null return `conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls and the context meter while preserving the editor and submit action. Closing the activity restores those controls with context details closed. The empty hero dock remains collapsed when it has no content. The occupant releases expansion on unmount and owns any activity-specific feedback. +Session title context gestures use the shared [context menu](../ui-context-menu/README.md) with the clicked breadcrumb’s Session. The reusable image gallery receives `getTriggerProps` from its Factory’s injected `useContextMenu` hook. + ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 84993c9f9e..b1d1c5fbd3 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -184,6 +184,8 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match `conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件,上下文详情保持关闭。首页输入框下方没有内容时,该区域保持收起。占用者在卸载时释放展开状态,并拥有活动专属反馈。 +会话标题右键使用共享[右键菜单](../ui-context-menu/README.zh.md),目标是被点击面包屑所属会话。可复用图片库从其 Factory 注入的 `useContextMenu` hook 获取 `getTriggerProps`。 + ## 模型体验 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index d2924b00e3..f168e7f6c8 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-ui-workspace" + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-ui-context-menu" ], "platform": "web" } @@ -97,7 +98,8 @@ "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", - "@deepseek-ai/dsh-message-quotes": "workspace:*" + "@deepseek-ai/dsh-message-quotes": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index b3c0d3bcd0..3328e1e5da 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,3 +1,4 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Registers the target-neutral Conversation assembly, shell, input, and docks. */ import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context } from '@deepseek-ai/cordis' diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index caec4849b4..369f562a7b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -475,6 +475,7 @@ export interface ConversationViewActionsOwnerProps { /** Business callbacks injected into the strict Session header. */ export interface ConversationSessionHeaderInjected { + /** Open the clicked title's actions. */ /** Package-owned View roster source bound only for the Conversation header. */ readonly hooks: { readonly conversationViews: ObservableSnapshot diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index d4f06accc5..7a39200c30 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,3 +1,4 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Browser Conversation assemble core, React adapter, shell, input, and text projection plugin. */ export type {} from './user-text/session-reference-rule.ts' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index deaddcf769..14602cbca0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -107,8 +107,9 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum */ export function ConversationSessionHeader({ sessionId, hideChrome, useSessions, useConversationViews, useStore, - renderSlot, open, selectView, setPinned, t, useEntryPins, useEntryActions, + renderSlot, open, useContextMenu, selectView, setPinned, t, useEntryPins, useEntryActions, }: ConversationSessionHeaderProps) { + const { getTriggerProps } = useContextMenu() const tabs = useConversationViews(value => value) const selectedId = useStore(s => s.view) const active = resolveActiveView(tabs, selectedId) @@ -176,7 +177,7 @@ export function ConversationSessionHeader({ ...last ? {} : { openTitle: () => { open(summary.id) } }, } return ( - + {index > 0 && /} {lineage ? summary.subagent diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 523fcffaae..d968bdc4d8 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.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 { render, cleanup } from '@testing-library/react' import { $getRoot, $isTextNode, PASTE_COMMAND } from 'lexical' import { createUserTextFixture } from './user-text-component-fixture.client.tsx' @@ -103,6 +104,8 @@ async function bench(renderQuote = false) { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, 'quote-probe': { kind: 'single', scope: 'session' }, diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index a7b0bfdbdf..154d31ec9f 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.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 { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages, @@ -25,6 +26,8 @@ async function bench(options: { declareConversation?: boolean } = {}) { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) if (options.declareConversation !== false) { await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index e4c2a2c037..c8e96efdbc 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.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' /** Conversation assembly acceptance independent of Tool presentation. */ import './control-row-dom.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -86,6 +87,8 @@ async function bench(opts?: { blank?: boolean }) { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, @@ -109,6 +112,8 @@ describe('resident composer', () => { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...inject], apply }) runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe) @@ -136,6 +141,8 @@ describe('resident composer', () => { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.workspaces.update((draft) => { draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never }) @@ -203,6 +210,8 @@ describe('prompt rejection through the assembled composer', () => { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) const prompt = vi.fn(async () => ({ ok: false, error: new RemoteError('session/agent-busy', 'prompt rejected before acceptance', { reason: 'busy' }), diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index f4b80c891c..3106eeb495 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -47,6 +48,7 @@ function mount() { useResource, useWorkspaces: emptyWorkspaces(), ...allowAllAccess, + useContextMenu: contextMenuHook(), useBusyEnter: bindSnapshotSelector(policy.busyEnter), setBusyEnter, t: makeTranslate(en), diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index a657abf806..da4c7dc61b 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' // InputBar behavior over the editor + submit-machine wiring: Enter-send // semantics (IME guard, Shift newline, busy Enter policy, Ctrl/Meta steering, // repeat suppression), running semantics (input stays free; continuable @@ -195,6 +196,7 @@ function bench(over?: BenchOptions) { items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })), ...allowAllAccess, + useContextMenu: contextMenuHook(), useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint), useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 6a18cede9a..517848498a 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' /** * Impact-matrix projection tests (row by row): what each * phase projects onto the InputBar — enter routing, visuals (command chip / @@ -67,6 +68,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled baselinesReady: true, recentWorkspaceId: undefined, })), ...allowAllAccess, + useContextMenu: contextMenuHook(), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 380097b5d1..2129c605d2 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' /** * Scenario-chain integration (scenarios A/C/D/H/I): the real per-session * InputTriggerController pipeline over a real session scope (Client Sessions over @@ -169,6 +170,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo baselinesReady: true, recentWorkspaceId: undefined, })), ...allowAllAccess, + useContextMenu: contextMenuHook(), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 4583814093..44878bbc18 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' /** * QueueDock rendering and operations: authoritative rows, inline editing, * collapse state, removal, QueueDock Steer, failure notices, and live retirement. @@ -118,6 +119,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial ), useWorkspaces: (() => { throw new Error('unused') }) as never, ...allowAllAccess, + useContextMenu: contextMenuHook(), useProjection: (() => undefined) as never, useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'], diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 2f7a1fd979..5f07662274 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createContext, useContext, type ReactNode } from 'react' @@ -235,7 +236,7 @@ function mount( useTrajectory={useTrajectory} useSessions={props.useSessions} usePanelInfo={props.usePanelInfo} - useResource={useResource} + useContextMenu={contextMenuHook()} useResource={useResource} useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} @@ -265,7 +266,7 @@ function mount( useTrajectory={useTrajectory} useSessions={props.useSessions} usePanelInfo={props.usePanelInfo} - useResource={useResource} + useContextMenu={contextMenuHook()} useResource={useResource} useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} @@ -289,7 +290,7 @@ function mount( children} - useResource={useResource} + useContextMenu={contextMenuHook()} useResource={useResource} useSession={useSession} useConversation={useConversation} useSessions={props.useSessions} @@ -353,6 +354,7 @@ function mount( useTrajectory, useSessions: bindSnapshotSelector(sessions), usePanelInfo: selector => selector({ activePanelId: null }), + useContextMenu: contextMenuHook(), useResource, useSessionStatus, useSessionRetainInfo: () => undefined, @@ -386,6 +388,7 @@ function mount( useSessions: bindSnapshotSelector(sessions), useSessionStatus, useSessionRetainInfo: () => undefined, + useContextMenu: contextMenuHook(), useResource, useWorkspaces: bindSnapshotSelector(workspaces), ...allowAllAccess, diff --git a/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx index f4970b398d..c5383f3091 100644 --- a/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx +++ b/packages/client/ui-conversation/tests/user-text-component-fixture.client.tsx @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { createElement, type ComponentProps, type ReactNode } from 'react' import { onTestFinished } from 'vitest' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, contextMenuHook, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { UserTextInput, UserTextNodeOwnerProps } from '../src/client/contract/slots.ts' import type { UserTextNode } from '../src/client/contract/user-text.ts' @@ -62,7 +62,7 @@ export function createUserTextFixture(actions: UserTextFixtureActions = {}): (in case 'secret': return createElement(SecretText, { ...owner, t } as ComponentProps) case 'text': return createElement(PlainUserText, owner as ComponentProps) case 'session': return createElement(SessionReference, owner as ComponentProps) - case 'file': return createElement(FileReference, { ...owner, openFile, t } as ComponentProps) + case 'file': return createElement(FileReference, { ...owner, openFile, t, useContextMenu: contextMenuHook(), resolveFilePath: (path: string) => path } as ComponentProps) case 'folder': return createElement(FolderReference, owner as ComponentProps) case 'skill': return createElement(SkillReference, { ...owner, openSkill } as ComponentProps) case 'command': return createElement(GoalCommandReference, owner as ComponentProps) diff --git a/packages/client/ui-conversation/tsconfig.client.json b/packages/client/ui-conversation/tsconfig.client.json index 0339eebbe6..0187cf5d72 100644 --- a/packages/client/ui-conversation/tsconfig.client.json +++ b/packages/client/ui-conversation/tsconfig.client.json @@ -104,6 +104,9 @@ }, { "path": "../product-analytics/tsconfig.client.json" + }, + { + "path": "../ui-context-menu" } ], "exclude": [ diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ff6839eee5..675bcff105 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -33,8 +33,8 @@ en: 7ee5ee783902662c zh: fdf2c244fb22b8ab /deepseek-ai-dsh-client-ui-deliverables/further-exploration: - en: 476b59e8671db982 - zh: b3d0495a03e8940a + en: 98eda99fafcb04b9 + zh: e6a21254c0383f48 /deepseek-ai-dsh-client-ui-deliverables/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 6727c12776..0632574d83 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -86,6 +86,8 @@ Read these pages when the deliverables surface is not enough. They move from the ----- +Delivery cards and known inline file mentions expose a [context menu](../ui-context-menu/README.md) for their declared Session file, preserving the original path and optional line. + ## Model Experience diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 45484627dd..2951888e72 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -86,6 +86,8 @@ Node 半部提供经过身份验证的原生文件打开;共用指导由[模 ----- +交付卡片和已知行内文件引用为其声明的会话文件提供[右键菜单](../ui-context-menu/README.zh.md),保留原始路径和可选行号。 + ## 模型体验 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index e64e93ae83..4172b1f4c2 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -37,7 +37,8 @@ "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-tool", - "@deepseek-ai/dsh-client-ui-settings" + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-context-menu" ], "platform": "web" } @@ -80,7 +81,8 @@ "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", - "@deepseek-ai/dsh-client-ui-settings": "workspace:*" + "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 544e7a82b2..89cfa24afa 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -66,11 +66,12 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail * @returns the closing turn's file rows. */ export function Deliverables({ - matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost, + matched, openFile, useContextMenu, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost, useChangesDiff, loadChangesDiff, useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot, }: Pick & { matched: DeliverablesMatch -} & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { +} & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { + const { getTriggerProps } = useContextMenu() const [expanded, setExpanded] = useState(false) const showCodeDiff = useShowCodeDiff(value => value) const cwd = useSessions(state => state.byId[sessionId]?.cwd) @@ -109,6 +110,7 @@ export function Deliverables({ phase={states[presentedFileUrl(sessionId, file.seq, file.index)]} host={host === 'error' ? null : host} t={t} onPreview={() => { openFile(file.path) }} + contextMenuProps={getTriggerProps({ kind: 'file', value: { sessionId, path: file.path } })} actions={renderSlot('deliverables.file.actions', { actionUrl: presentedFileUrl(sessionId, file.seq, file.index), available: host !== null && host !== 'error' && host.available, diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index b40bfa40a0..6e71d25d15 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,3 +1,4 @@ +import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client' /** File identity, Sidebar preview, and contributed native actions for one delivery. */ import type { ReactNode } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -19,12 +20,13 @@ function cardDescription(description: string | undefined, fallback: string): str * @param props - durable file metadata, Sidebar preview, Host capabilities, gesture status, and localized copy. * @returns the file card and its anchored action menu. */ -export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions, t }: { +export function PresentedFileCard({ file, cwd, phase, host, onPreview, contextMenuProps, actions, t }: { file: PresentedPath cwd: string | undefined phase: PresentedOpenPhase | undefined host: PresentedHost | null onPreview: () => void + contextMenuProps: ContextMenuTriggerProps actions: ReactNode } & PropsLocale) { const reveal = host?.fileManager ?? 'directory' @@ -36,7 +38,8 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions, : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) return
) + fireEvent.contextMenu(trigger.getByRole('button'), { clientX: 41, clientY: 52 }) + trigger.unmount() + expect(vi.spyOn(ctx.contextMenus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'out/report.docx' } }, { x: 41, y: 52 }) mention?.open() } } @@ -1014,6 +1025,8 @@ describe('presented files', () => { const props = openProps() props.openPresented.mockResolvedValue({ kind: 'opened' }) const view = render() + fireEvent.contextMenu(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' }), { clientX: 18, clientY: 29 }) + expect(props.openMenu).toHaveBeenCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'report-0.docx' } }, { x: 18, y: 29 }) expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) const expand = view.getByRole('button', { name: 'Show all 8 delivered files' }) expect(expand.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 192fb447de..0b009d5915 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -2,20 +2,25 @@ /** Explicit file actions preserve their destination, availability, and independent failure state. */ import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { contextMenuHook, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { PresentedFileCard } from '../src/client/PresentedFileCard.tsx' import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) -const props = () => ({ - cwd: undefined, - file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, - host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, - phase: undefined, - onPreview: vi.fn(), - actions: , - t: makeTranslate(en), -}) +const props = () => { + const onMenu = vi.fn() + return { + cwd: undefined, + file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, + host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, + phase: undefined, + onPreview: vi.fn(), + onMenu, contextMenuProps: contextMenuHook((_target, point) => { onMenu(point) })() + .getTriggerProps({ kind: 'image', value: { url: 'unused' } }), + actions: , + t: makeTranslate(en), + } +} it('localizes reveal failures and restores the description when progress clears', () => { const p = props() const view = render() @@ -66,6 +71,8 @@ it('renders the supplied action independently from the card preview', () => { fireEvent.click(view.getByRole('button', { name: 'Native file action' })) expect(native).toHaveBeenCalledOnce() expect(p.onPreview).not.toHaveBeenCalled() + fireEvent.contextMenu(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }), { clientX: 26, clientY: 37 }) + expect(p.onMenu).toHaveBeenCalledWith({ x: 26, y: 37 }) fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) expect(p.onPreview).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json index 4fe248edd6..bd78605122 100644 --- a/packages/client/ui-deliverables/tsconfig.client.json +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -68,6 +68,9 @@ }, { "path": "../../util/workspace-path" + }, + { + "path": "../ui-context-menu" } ] } diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 846a4b75e8..36ec07ca0b 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -9,9 +9,9 @@ * drop target changes nothing. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { ReactNode } from 'react' +import type { ReactNode, HTMLAttributes } from 'react' import type { DockIntents, DockLabels, TabMenuExtras, TabRenderer } from '../contract/adapter.ts' -import type { LayoutState, PaneId, SplitId, TabId } from '../contract/types.ts' +import type { LayoutState, PaneId, SplitId, TabId, TabRecord } from '../contract/types.ts' import { clampSizes, FLOAT_DEFAULT_SIZE, MIN_PANE_FRACTION } from '../engine/constraints.ts' import { getSplit, topRightPaneId } from '../engine/tree.ts' import type { DropTarget, HalvesFit } from '../engine/geometry.ts' @@ -63,7 +63,11 @@ export interface DockSurfaceProps { * slot, and nothing outside that embedder is expected to supply it. */ readonly renderTabTitle?: TabRenderer - /** Extra items for a tab's context menu; omit for the kit's own item only. */ + /** Whether the embedder popup is preparing or visible; suppresses tab tooltips. */ + readonly tabMenuOpen?: boolean | undefined + /** Override the kit popup; the embedder owns rendering and dismissal. */ + readonly getTabTriggerProps?: ((tab: TabRecord, paneId: PaneId, handlers?: Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) => Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) | undefined + /** Extra rows for the kit popup; ignored when getTabTriggerProps is supplied. */ readonly renderTabMenuItems?: TabMenuExtras /** * Surface-wide controls, drawn at the far end of the top-right pane's tab @@ -169,7 +173,8 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean { /** The split tree and the gestures over it. */ function Surface({ - state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, + state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, + getTabTriggerProps, tabMenuOpen, chrome, onRoom, draw, dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false, }: DockSurfaceProps & { readonly draw: (callbacks: PaneCallbacks, preview: SizePreview | undefined) => ReactNode }): ReactNode { @@ -291,6 +296,7 @@ function Surface({ renderTab, renderTabTitle, renderTabMenuItems, + getTabTriggerProps, tabMenuOpen, chromePaneId: topRightPaneId(state), chrome, } diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index df0e39bdb4..a7d2e721ea 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -22,6 +22,7 @@ * `canAddTab` allows), the pane's split control, and the embedder's chrome keep * their width and their place at the strip's end. */ +import type { KeyboardEvent } from 'react' import { Fragment, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode, RefObject } from 'react' import clsx from 'clsx' @@ -303,6 +304,13 @@ export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): Re // A pane's lone unclosable chip is a label, not a choice: there is // no other tab to select against and nothing to do to it. const quiet = !closable && pane.tabs.length === 1 + const onKeyDown = (event: KeyboardEvent) => { + if (event.target !== event.currentTarget) return + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return + const next = chipToFocus(event.key, pane.tabs, tabId) + if (next !== undefined) { event.preventDefault(); focusChip(next); return } + if (selects(event.key)) { event.preventDefault(); activate(tabId) } + } return ( {(index > 0 || stripIndex === index) && ( @@ -350,30 +358,18 @@ export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): Re event.stopPropagation() activate(tabId) }} - onKeyDown={(event) => { - // Keys on the chip's nested close control are that control's. - if (event.target !== event.currentTarget) return - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return - const next = chipToFocus(event.key, pane.tabs, tabId) - if (next !== undefined) { - event.preventDefault() - focusChip(next) - return - } - if (selects(event.key)) { - event.preventDefault() - activate(tabId) - } - }} - onContextMenu={(event) => { - event.preventDefault() - const anchor = event.currentTarget - setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor }) + {...callbacks.getTabTriggerProps?.(tab, pane.id, { onKeyDown, tabIndex: selected ? 0 : -1 }) ?? { + onKeyDown, + onContextMenu: (event) => { + event.preventDefault(); event.stopPropagation() + const anchor = event.currentTarget + setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor }) + }, }} > {callbacks.renderTabTitle?.(tab) ?? tab.title} {closable && ( - + diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 1fd68864bb..d3c24aad2b 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -34,6 +34,11 @@ max-width: 360px; } +/* Legacy slots can render no rows; keep empty context popups out of layout. */ +.list[data-context-menu]:not(:has([role='menuitem'])) { + display: none; +} + /* Portal mode: fixed in the viewport, coordinates supplied inline from the * anchor rect (side/align resolved in JS, the in-place offset rules above * don't apply). Portaled lists must layer above modal overlays (z 1000) — @@ -87,6 +92,12 @@ border-top: 0.5px solid var(--dsw-alias-border-l2); } +.list[data-context-menu] .footer:has(> .separator:first-child) { + margin-top: 0; + padding-top: 0; + border-top: none; +} + .itemWrap { position: relative; } @@ -153,7 +164,7 @@ } .compactList .separator { - margin: 2px; + margin: 2px 6px; } .compactList .label { @@ -258,11 +269,11 @@ color: var(--dsw-alias-label-tertiary); } -/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. l2, not - * l1, for the same reason as .footer: l1 is near-invisible on this surface. */ +/* Separator endpoints align with the row content. The l2 stroke stays visible + * on the menu surface. */ .separator { height: 0.5px; - margin: 3px 2px; + margin: 3px 8px; background: var(--dsw-alias-border-l2); } @@ -274,6 +285,11 @@ display: none; } +/* A legacy outlet following structured rows is not the beginning of the menu. */ +.list[data-context-menu] .viewport > .itemWrap ~ [data-slot] .itemWrap:first-child > .separator:first-child { + display: block; +} + /* The submenu aligns with the parent's bottom edge. Its hit bridge covers * the menu inset and the 6px gap so pointer movement keeps it open. */ .submenu { @@ -293,3 +309,7 @@ left: -10px; width: 10px; } + +.list[data-menu-phase='dismiss'], +.list[data-menu-phase='replace'] { pointer-events: none; } +.list[data-context-menu].scrollable { max-height: calc(100vh - 24px); } diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 90f76edd1a..753de26f95 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode, SyntheticEvent } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' @@ -24,7 +24,7 @@ export interface MenuItem { /** Destructive row: error-colored text/icon and danger hover fill. */ danger?: boolean /** Nested card opened to the right on hover/focus. */ - submenu?: readonly MenuItem[] + submenu?: readonly MenuEntry[] } /** Hairline between item groups (not selectable). */ @@ -115,6 +115,25 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { return 'type' in entry && entry.type === 'label' } +/** Mark icon columns within each separator-delimited data group. */ +function iconColumns(entries: readonly MenuEntry[], contextual: boolean): ReadonlySet { + const aligned = new Set() + if (!contextual) return aligned + let group: MenuEntry[] = [] + const finish = (): void => { + if (group.some(entry => !isLabel(entry) && !isSeparator(entry) && entry.icon !== undefined)) { + for (const entry of group) aligned.add(entry) + } + group = [] + } + for (const entry of entries) { + if (isSeparator(entry)) finish() + else group.push(entry) + } + finish() + return aligned +} + /** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } @@ -125,6 +144,9 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * anchor's first button, and selecting a row does the same — the rows unmount * with the list. Only a keyboard on the trigger or inside the list is * intercepted; Tab presses elsewhere on the page stay the browser's. + * @param props.contextual - cursor positioning, context dismissal, and per-divider icon alignment. + * @param props.exiting - retain an inert popup during dismissal or replacement fade. + * @param props.returnFocus - original control captured before asynchronous menu preparation. * @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). @@ -155,7 +177,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * frame, and scroll/resize; return null to skip placement for that frame. * @param props.minWidthFromAnchor - in portal mode, keep the menu at least as wide as its anchor, bounded by viewport margins. * @param props.footer - rows pinned below the scrolling items area, separated - * by a hairline; they stay visible while the items above scroll. + * by a hairline; contextual menus reuse an explicit leading separator. These rows stay visible while the items above scroll. * @param props.children - component rows rendered after `items` in the same * list, each a `role="menuitem"` button such as {@link MenuItemButton}; they * share the keyboard walk, the submenu exclusivity, and the post-selection @@ -170,8 +192,14 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * document.body outside the owner's DOM subtree. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName }: { +export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: { open: boolean + /** Pointer-positioned popup: cursor clearance, grouped icons, and context gestures. */ + contextual?: boolean + /** Resolve the original control for focus restoration and inert/hidden dismissal. */ + returnFocus?: (() => HTMLElement | null) | undefined + /** Retain a dismissed context popup during its short fade. */ + exiting?: 'dismiss' | 'replace' | undefined autoFocus?: boolean anchor: ReactNode items?: readonly MenuEntry[] @@ -195,6 +223,17 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI }) { const rootRef = useRef(null) const listRef = useRef(null) + const menuId = useId() + useLayoutEffect(() => { + const trigger = returnFocus?.() + if (!open || !contextual || trigger == null) return + const previous = trigger.getAttribute('aria-controls') + trigger.setAttribute('aria-controls', [previous, menuId].filter(Boolean).join(' ')) + return () => { + if (previous === null) trigger.removeAttribute('aria-controls') + else trigger.setAttribute('aria-controls', previous) + } + }, [open, contextual, returnFocus, menuId]) /** Index the arrow walk last focused, the resume point when focus left the rows. */ const walkIndex = useRef(null) /** @@ -213,7 +252,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI * @param navigation - whether explicit keyboard traversal should retain its focus indicator. */ const refocusAnchor = (navigation = false): void => { - const trigger = triggerRef.current + const trigger = returnFocus?.() ?? triggerRef.current const target = trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled ? trigger : rootRef.current?.querySelector('button:not(:disabled)') if (target == null) return @@ -231,11 +270,13 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI const refocusAfterSelection = (): void => { const navigation = selectingWithTab.current queueMicrotask(() => { - if (openRef.current) return + if (openRef.current && exitingRef.current === undefined) return const active = document.activeElement if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor(navigation) }) } + const exitingRef = useRef(exiting) + exitingRef.current = exiting const openRef = useRef(open) openRef.current = open const [openSubmenuId, setOpenSubmenuId] = useState(null) @@ -252,8 +293,9 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI useLayoutEffect(() => { if (!open || !portal) { setFixedPos(null); return } const place = () => { - if (rootRef.current?.closest('[inert], [hidden]') != null) { - close.current() + const anchorElement = returnFocus?.() ?? rootRef.current + if (anchorElement?.closest('[inert], [hidden]') != null) { + if (exitingRef.current === undefined) close.current() return } let r: DOMRect | null @@ -290,7 +332,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) - if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, contextual ? MARGIN : overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN) setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y }) } @@ -311,49 +353,49 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, portal, align, side, getAnchorRect, minWidthFromAnchor]) + }, [open, portal, align, side, getAnchorRect, minWidthFromAnchor, contextual, returnFocus]) // Opening remembers where the keyboard was, so closing can hand it back to // that control — an anchor wrapping several (a split button) cannot be asked // for it by position. Declared before the autoFocus effect so the capture // sees the trigger, not the row autoFocus is about to focus. useEffect(() => { - if (!open) { - triggerRef.current = null - return - } + if (!open) return const active = document.activeElement - triggerRef.current = active instanceof HTMLElement && rootRef.current?.contains(active) === true ? active : null + triggerRef.current = active instanceof HTMLElement && (getAnchorRect !== undefined || rootRef.current?.contains(active) === true) + ? active : null }, [open]) useEffect(() => { - if (!open || !autoFocus) return + if (!open || exiting !== undefined || (portal && fixedPos === null) || !autoFocus + || listRef.current?.contains(document.activeElement)) return const first = listRef.current?.querySelector('button:not(:disabled)') walkIndex.current = first === undefined || first === null ? null : 0 if (first != null) focusWithoutRing(first) - }, [open, autoFocus]) + }, [open, autoFocus, items.length, footer?.length, fixedPos, portal, exiting]) useEffect(() => { - if (!open) { + if (!open || exiting !== undefined) { setOpenSubmenuId(null) walkIndex.current = null return } const composition = observeComposition(document) const onPointerDown = (e: PointerEvent) => { - if (!(e.target instanceof Node)) return + if (!(e.target instanceof Node) || (contextual && e.button === 2)) return // The portaled list is outside the anchor subtree; check both. if (rootRef.current?.contains(e.target) === true) return if (listRef.current?.contains(e.target) === true) return onClose() } const onKeyDown = (e: KeyboardEvent) => { - if (composition.guards(e) || isBehindModal(rootRef.current) || e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey) return + if (composition.guards(e) || isBehindModal(triggerRef.current ?? rootRef.current) + || e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey) return // Where the keyboard is, computed once: the menu owns it when it holds a // row or sits on its anchor region. const focused = document.activeElement const insideList = listRef.current?.contains(focused) === true - const anchored = rootRef.current?.contains(focused) === true || insideList + const anchored = focused === returnFocus?.() || rootRef.current?.contains(focused) === true || insideList if (e.key === 'Escape' && !e.shiftKey) { e.preventDefault() if (e.repeat) return @@ -439,7 +481,11 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI document.removeEventListener('keydown', onEscape, true) window.removeEventListener('blur', onWindowBlur) } - }, [open, onClose, autoFocus]) + }, [open, onClose, autoFocus, contextual, exiting]) + + useLayoutEffect(() => { + if (exiting === 'dismiss' && listRef.current?.contains(document.activeElement)) refocusAnchor() + }, [exiting]) // A close from selection/Escape/outside click outruns a pending grace close; // left armed it would shut a list reopened inside the grace window. Its own @@ -453,7 +499,9 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI // scroll clip would crop it, so only submenu-free menus get the height cap. const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) - const renderEntry = (entry: MenuEntry) => { + const itemColumns = iconColumns(items, contextual) + const footerColumns = iconColumns(footer ?? [], contextual) + const renderEntry = (entry: MenuEntry, columns: ReadonlySet) => { if (isSeparator(entry)) { return
} @@ -462,6 +510,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id + const subColumns = iconColumns(entry.submenu ?? [], contextual) const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true return (
- {entry.icon !== undefined && {entry.icon}} + {(entry.icon !== undefined || columns.has(entry)) && } {entry.label} {entry.shortcut !== undefined && } {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} @@ -495,21 +544,23 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI {subOpen && entry.submenu !== undefined && ( - {entry.submenu.map(sub => ( - - ))} + {entry.submenu.map(sub => isSeparator(sub) + ?
+ : isLabel(sub) ?
{sub.text}
: ( + + ))} )}
@@ -532,11 +583,14 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI // already at the final position (with getAnchorRect returning null the // list simply stays hidden). const list = open && ( - { + event.preventDefault(); event.stopPropagation(); onClose() + } : undefined} onMouseOver={collapseSubmenuFrom} onFocus={collapseSubmenuFrom} >
- {items.map(renderEntry)} + {items.map(entry => renderEntry(entry, itemColumns))} {children}
{footer !== undefined && footer.length > 0 && (
- {footer.map(renderEntry)} + {footer.map(entry => renderEntry(entry, footerColumns))}
)}
diff --git a/packages/client/ui-primitives/src/MenuSurface.module.css b/packages/client/ui-primitives/src/MenuSurface.module.css index 57d9308455..896264095d 100644 --- a/packages/client/ui-primitives/src/MenuSurface.module.css +++ b/packages/client/ui-primitives/src/MenuSurface.module.css @@ -47,3 +47,19 @@ background: var(--dsw-alias-bg-base); pointer-events: none; } + +/* Opacity on the container would prevent its material from blurring the page. + * Enter animations release their compositing layers so nested menus can blur too. */ +.surface[data-menu-phase='enter'] > *, +.backing[data-menu-phase='enter'] { animation: dsh-context-menu-in var(--dsh-menu-enter) ease-out; } +.surface[data-menu-phase='dismiss'] > *, +.backing[data-menu-phase='dismiss'], +.surface[data-menu-phase='replace'] > *, +.backing[data-menu-phase='replace'] { animation: dsh-context-menu-out var(--dsh-menu-dismiss) ease-in both; } +.surface[data-menu-phase='replace'] > *, +.backing[data-menu-phase='replace'] { animation-duration: var(--dsh-menu-replace); } +@keyframes dsh-context-menu-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes dsh-context-menu-out { from { opacity: 1; } to { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { + .surface[data-menu-phase] > *, .backing[data-menu-phase] { animation-duration: 0ms; } +} diff --git a/packages/client/ui-primitives/src/MenuSurface.tsx b/packages/client/ui-primitives/src/MenuSurface.tsx index 1bdefd0d6a..8e54773170 100644 --- a/packages/client/ui-primitives/src/MenuSurface.tsx +++ b/packages/client/ui-primitives/src/MenuSurface.tsx @@ -4,10 +4,15 @@ import { createPortal } from 'react-dom' import clsx from 'clsx' import css from './MenuSurface.module.css' +/** Context-menu transition durations in milliseconds, shared with its lifecycle owner. */ +export const MENU_TRANSITION_MS = Object.freeze({ enter: 40, dismiss: 80, replace: 10, prepare: 20 }) + /** Menu containers preserve native div props and refs. */ export interface MenuSurfaceProps extends ComponentPropsWithoutRef<'div'> { /** Match the shared compact menu's smaller outer radius. */ compact?: boolean + /** Fade direct content, material, and native backing without isolating the material's backdrop. */ + phase?: 'enter' | 'dismiss' | 'replace' | undefined } /** @@ -18,7 +23,7 @@ export interface MenuSurfaceProps extends ComponentPropsWithoutRef<'div'> { * @returns Menu content plus a backing portal removed with the menu. */ export const MenuSurface = forwardRef(function MenuSurface({ - compact = false, className, style, children, ...props + compact = false, phase, className, style, children, ...props }, ref) { const id = useId() const backingRef = useRef(null) @@ -28,17 +33,20 @@ export const MenuSurface = forwardRef(function // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The portal ref is attached before layout effects run. document.body.appendChild(backingRef.current!) }, []) - const anchorStyle: CSSProperties & { '--dsh-menu-anchor': string } = { + const anchorStyle: CSSProperties & Record<`--dsh-menu-${string}`, string> = { '--dsh-menu-anchor': `--dsh-menu-${id.replaceAll(':', '')}`, + '--dsh-menu-enter': `${MENU_TRANSITION_MS.enter}ms`, + '--dsh-menu-dismiss': `${MENU_TRANSITION_MS.dismiss}ms`, + '--dsh-menu-replace': `${MENU_TRANSITION_MS.replace}ms`, } return <> -
{createPortal( -