mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
Merge pull request #6951 from deepseek-harness/feat/extensible-context-menus
feat(client): 统一可扩展右键菜单与原始文件操作
This commit is contained in:
+19
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 条目不参与纯数据解析,因此原生渲染不能假定功能完全一致。注册表测试覆盖这些生命周期;浏览器场景覆盖选择不变、原始字节以及嵌套浮层关闭。
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<a id="low-level-development-overrides"></a>
|
||||
|
||||
## Low-level development overrides
|
||||
|
||||
@@ -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) 定义保存和剪贴板行为。
|
||||
|
||||
<a id="low-level-development-overrides"></a>
|
||||
|
||||
## 底层开发覆盖项
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, {
|
||||
event: IpcMainInvokeEvent
|
||||
owner: BrowserWindow
|
||||
request: ReturnType<typeof saveRequest>
|
||||
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<FileSaveResult> => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
|| !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 }
|
||||
|
||||
@@ -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<FileSaveResult>,
|
||||
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
|
||||
|
||||
@@ -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<unknown> => {
|
||||
const handler = electron.ipcMain.handle.mock.calls.find(([name]) => name === channel)?.[1] as
|
||||
(event: IpcMainInvokeEvent, value: unknown) => Promise<unknown>
|
||||
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<Response>((_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<unknown>
|
||||
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([])
|
||||
})
|
||||
@@ -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 },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/product-analytics/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-file-actions"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/file-drag"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-file-actions"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string[]> {
|
||||
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 \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<typeof watchConsole>
|
||||
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<HTMLElement>('[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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Copy image
|
||||
Download
|
||||
Show in folder
|
||||
@@ -0,0 +1,6 @@
|
||||
Pin session
|
||||
Rename
|
||||
Fork session
|
||||
Copy Session ID
|
||||
Show in folder
|
||||
Archive session
|
||||
@@ -0,0 +1,3 @@
|
||||
Rename
|
||||
Show in folder
|
||||
Delete workspace
|
||||
@@ -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"
|
||||
|
||||
@@ -2,5 +2,7 @@
|
||||
- menuitem "Pin session"
|
||||
- menuitem "Rename"
|
||||
- menuitem "Fork session"
|
||||
- menuitem "Archive session"
|
||||
- separator
|
||||
- menuitem "Copy Session ID"
|
||||
- separator
|
||||
- menuitem "Archive session"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
- menu:
|
||||
- menuitem "Close"
|
||||
- menuitem "Close Other Tabs" [disabled]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof watchConsole>
|
||||
|
||||
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)
|
||||
|
||||
@@ -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<HTMLElement>(':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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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` |
|
||||
<!-- END GENERATED event-producer-consumer:undeclared -->
|
||||
|
||||
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
|
||||
|
||||
@@ -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` |
|
||||
<!-- END GENERATED event-producer-consumer:undeclared -->
|
||||
|
||||
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -67,6 +67,8 @@ Slot 声明固定两个相互独立的维度。
|
||||
|
||||
<a id="component-inputs"></a>
|
||||
|
||||
[右键菜单插件](../../packages/client/ui-context-menu/README.zh.md) 通过 `provideRoot` 贡献 `useContextMenu`;每个作用域接收同一个由插件拥有的触发器工厂。提供方卸载时,注入属性与保留的处理器一并失效。其根作用域 keyed Slot `contextMenu.icon` 根据操作元数据渲染业务方提供的图标。
|
||||
|
||||
## 组件输入
|
||||
|
||||
注册组件会在 binding 位置收到组装后的输入。组件应从这些类型推导 props,不要重新抄写成员。
|
||||
|
||||
@@ -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
|
||||
@@ -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 `<img>`. 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.
|
||||
@@ -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` 改为图标元素,并用 `<img>` 包装图标 URL。在每个实际 Slot 入口获取 `useContextMenu`。普通子组件接收 `getTriggerProps` 或绑定后的 `contextMenuProps`;ToolRow 使用 `fileContextMenuProps` 和 `urlContextMenuProps`。删除 Chat 消息流和节点 owner 的菜单字段。助手入口向 `forClosing()` 传入普通文件适配函数,Markdown 则在 `MarkdownDelegateProvider.getTriggerProps` 处适配目标类型。业务注册保留 Session 归属和触发时路径解析。删除 `contextMenuTrigger` 和 `menuPoint` 导入,其独立工具包已删除。不要向 ui-primitives 增加手势导出。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 插件,确认类型检查通过,并打开菜单验证图标渲染和插件卸载后的清理。
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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:~",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AgentPresetSettingsState> = {}, inMenu = false) {
|
||||
usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook,
|
||||
useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook,
|
||||
...allowAllAccess,
|
||||
useContextMenu: contextMenuHook(),
|
||||
load, startCreatorDraft, onDismiss,
|
||||
}
|
||||
function TestMenu() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -24,6 +25,7 @@ function view(partial: Partial<AgentPresetSectionState> = {}, 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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ This package renders everything the conversation UI shows about attachments: one
|
||||
<a id="use-this-package"></a>
|
||||
## 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.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
图片拖放回调与输入框的原图读取回调通过 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 元数据独立于缩略图展示。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
@@ -135,6 +140,7 @@ export function MessageImage({ image, load, variant, labels, fileDrag }: {
|
||||
title={loadingThumbnail ? labels.loading : labels.open}
|
||||
aria-label={loadingThumbnail ? labels.loading : labels.openNamed(label)}
|
||||
aria-busy={loadingThumbnail || undefined}
|
||||
{...contextMenuProps}
|
||||
onClick={() => { if (src !== null) setOpen(true) }}
|
||||
>
|
||||
{src === null
|
||||
@@ -146,14 +152,15 @@ export function MessageImage({ image, load, variant, labels, fileDrag }: {
|
||||
: <DraggableImage fileDrag={fileDrag} src={src} alt={label} name={preview?.name ?? attachment?.name ?? 'image'}
|
||||
style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close}
|
||||
contextMenuProps={contextMenuProps} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,12 @@ type MessageImagesProps = MessageImagesOwnerProps & PropsRenderFactories
|
||||
* @param props - image sources, authorized loader, layout hints, optional labels, and Factory locale seat.
|
||||
* @returns the official gallery; no Session binding is needed by this component.
|
||||
*/
|
||||
export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag }:
|
||||
export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag, useContextMenu }:
|
||||
FactoryComponentPropsOf<'conversation.images.content'>) {
|
||||
const { getTriggerProps } = useContextMenu()
|
||||
const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t])
|
||||
return <ImageGallery images={images} load={loadImage} align={align}
|
||||
compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} />
|
||||
compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} getTriggerProps={getTriggerProps} />
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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<string>(() => {})), {
|
||||
peek: vi.fn(() => 'blob:seeded'),
|
||||
})
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
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(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
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<string>(() => {}))
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
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(<MessageImage fileDrag={fileDrag} image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
|
||||
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(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
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(
|
||||
<MessageImage fileDrag={fileDrag} image={{ attachment: { ...attachment, width: 100, height: 2000 } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment: { ...attachment, width: 100, height: 2000 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
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(
|
||||
<MessageImage fileDrag={fileDrag} image={{ attachment: { ...attachment, width: 4000, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment: { ...attachment, width: 4000, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
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(
|
||||
<MessageImage fileDrag={fileDrag} image={{ attachment: { ...attachment, width: 100, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment: { ...attachment, width: 100, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
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<string>(() => {}))
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="tile" labels={labels} />)
|
||||
const view = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment }} load={load} variant="tile" labels={labels} />)
|
||||
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(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant={variant} labels={labels} />)
|
||||
const view = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps}
|
||||
fileDrag={fileDrag} image={{ attachment }} load={load} variant={variant} labels={labels} />)
|
||||
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<string>()
|
||||
const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry')
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="thumbnail" labels={statusLabels} />)
|
||||
const view = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment }} load={load} variant="thumbnail" labels={statusLabels} />)
|
||||
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(<ImageGallery fileDrag={fileDrag} images={[{ attachment: unnamed, label: 'Image 2' }]} load={load} align="start" thumbnail labels={labels} />)
|
||||
const view = render(<ImageGallery
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} images={[{ attachment: unnamed, label: 'Image 2' }]} load={load} align="start" thumbnail labels={labels} />)
|
||||
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<string>((r) => { resolve = r }))
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
view.unmount()
|
||||
resolve?.('blob:late')
|
||||
await Promise.resolve()
|
||||
let reject: ((error: Error) => void) | undefined
|
||||
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
|
||||
const second = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={failing} variant="single" labels={labels} />)
|
||||
const second = render(<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ attachment }} load={failing} variant="single" labels={labels} />)
|
||||
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(
|
||||
<MessageImage fileDrag={fileDrag}
|
||||
<MessageImage getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag}
|
||||
image={{ preview: { url: 'blob:echo', name: 'echo.png', width: 640, height: 320 } }}
|
||||
load={load}
|
||||
variant="single"
|
||||
@@ -217,7 +233,8 @@ describe('MessageImage preview arm', () => {
|
||||
it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => {
|
||||
const load = vi.fn()
|
||||
const view = render(
|
||||
<MessageImage fileDrag={fileDrag} image={{ preview: { url: 'blob:unprobed' } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ preview: { url: 'blob:unprobed' } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
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(
|
||||
<MessageImage fileDrag={fileDrag} image={{ preview: { url: 'blob:box' } }} load={vi.fn(async () => '')} variant="tile" labels={labels} />,
|
||||
<MessageImage
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} image={{ preview: { url: 'blob:box' } }} load={vi.fn(async () => '')} 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(<ImageGallery fileDrag={fileDrag} images={[]} load={load} align="start" labels={labels} />)
|
||||
const empty = render(<ImageGallery
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} images={[]} load={load} align="start" labels={labels} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const view = render(
|
||||
<ImageGallery fileDrag={fileDrag}
|
||||
<ImageGallery getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag}
|
||||
images={[{ attachment }, { preview: { url: 'blob:echo', name: 'echo.png' } }, { attachment }]}
|
||||
load={load}
|
||||
align="end"
|
||||
@@ -254,16 +273,19 @@ describe('ImageGallery', () => {
|
||||
|
||||
it('renders a lone image large and compact or grouped images as square tiles', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const lone = render(<ImageGallery fileDrag={fileDrag} images={[{ attachment }]} load={load} align="start" labels={labels} />)
|
||||
const lone = render(<ImageGallery
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} images={[{ attachment }]} load={load} align="start" labels={labels} />)
|
||||
expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
|
||||
lone.unmount()
|
||||
const compact = render(
|
||||
<ImageGallery fileDrag={fileDrag} images={[{ attachment }]} load={load} align="end" compact labels={labels} />,
|
||||
<ImageGallery
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} images={[{ attachment }]} load={load} align="end" compact labels={labels} />,
|
||||
)
|
||||
expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1)
|
||||
compact.unmount()
|
||||
const several = render(
|
||||
<ImageGallery fileDrag={fileDrag} images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||
<ImageGallery
|
||||
getTriggerProps={contextMenuHook()().getTriggerProps} fileDrag={fileDrag} images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||
)
|
||||
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(<MessageImage
|
||||
getTriggerProps={contextMenuHook(openImageMenu)().getTriggerProps} fileDrag={fileDrag} image={{ attachment }} load={vi.fn(async () => '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 })
|
||||
})
|
||||
|
||||
@@ -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 } }])
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/file-drag"
|
||||
},
|
||||
{
|
||||
"path": "../ui-context-menu"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
-----
|
||||
|
||||
<a id="turn-token-usage"></a>
|
||||
@@ -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.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -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 的当前工作目录解析路径,菜单保留捕获的路径和行号,不受焦点变化影响。未声明的插件链接保留原生行为。
|
||||
|
||||
-----
|
||||
|
||||
<a id="turn-token-usage"></a>
|
||||
@@ -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)。文件目标保留工作目录和行号;普通链接点击仍遵循配置的打开偏好。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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:~"
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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<PresentationInjected>
|
||||
/** 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<Parameters<ChatFileMentions['forClosing']>[2]>) => ReturnType<ChatFileMentions['forClosing']>
|
||||
}
|
||||
|
||||
type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace<AssistantNodeInjected>
|
||||
& 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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<readonly RenderEntry[]>(() => groupedEntries
|
||||
@@ -230,10 +243,15 @@ export function ChatView({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MarkdownDelegateProvider openFile={requestOpenFile} openExternalLink={openExternalLink} fileImages={fileImages}>
|
||||
<MarkdownDelegateProvider
|
||||
getTriggerProps={getMarkdownTriggerProps}
|
||||
openFile={requestOpenFile}
|
||||
openExternalLink={openExternalLink}
|
||||
fileImages={fileImages}
|
||||
>
|
||||
{renderSlot('conversation.chat.flow', {
|
||||
entries, pendingInputs, lastInputTurn, deferCollapse,
|
||||
cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, fileMentions,
|
||||
cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage,
|
||||
}, { hookContext: flowContext })}
|
||||
</MarkdownDelegateProvider>
|
||||
<div ref={interactionSurfaceRef} className={css.textInteraction} data-text-interaction-surface=""
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { PerformanceUsageMode } from '../../chat-settings.ts'
|
||||
import type { ChatPresentationPolicy } from '../presentation-policy.ts'
|
||||
import { NS } from '../locale.ts'
|
||||
import { AssistantNodeView } from './AssistantNodeView.tsx'
|
||||
import { AssistantNodeView, type AssistantNodeInjected } from './AssistantNodeView.tsx'
|
||||
import { sessionFilePath } from '../file-path.ts'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
|
||||
import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
|
||||
@@ -60,7 +61,11 @@ export function registerChatNodeRenderers(
|
||||
'conversation.chat.reasoning.body': { kind: 'single', scope: 'session' },
|
||||
'conversation.chat.message.images': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
inject: () => ({ 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',
|
||||
|
||||
@@ -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<ChatViewInjected, 'keyedHooks'> & {
|
||||
|
||||
/** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */
|
||||
export interface ChatFlowOwnerProps extends Pick<ChatNodeOwnerProps,
|
||||
'cwd' | 'openFile' | 'inspectCall' | 'forkAt' | 'loadImage' | 'fileMentions'> {
|
||||
'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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<React.ComponentProps<typeof AssistantNodeView>, '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<SessionStatusSnapshot>(new Map()),
|
||||
@@ -706,6 +710,41 @@ function formatRunDuration(ms: number, t: Parameters<typeof durationParts>[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) => <button key={index} {...getTriggerProps!(target)}>{index}</button>)}</>
|
||||
}
|
||||
const view = render(<ChatView {...h.props} renderSlot={() => <References />} />)
|
||||
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(<ChatView {...h.props} renderSlot={() => <>
|
||||
<a href="https://example.com/plugin">Plugin link</a>
|
||||
<button data-file-path="/plugin/private.txt">Undeclared file</button>
|
||||
<button onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
custom()
|
||||
}}>Custom target</button>
|
||||
</>} />)
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -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(<MarkdownDelegateProvider getTriggerProps={contextMenuHook(open)().getTriggerProps} openFile={() => {}}>
|
||||
<a href="https://example.com/plugin">Plugin link</a>
|
||||
<MarkdownText text="[File](docs/file.ts#L42)" labels={{ code: { copyLabel: 'Copy', copiedLabel: 'Copied' }, footnotes: 'Footnotes' }} />
|
||||
<button {...contextMenuHook(custom)().getTriggerProps({ kind: 'pluginDocument', id: 'one' })}>Plugin target</button>
|
||||
</MarkdownDelegateProvider>)
|
||||
expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
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)
|
||||
})
|
||||
@@ -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' },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -122,6 +122,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/tool-executor-ptc"
|
||||
},
|
||||
{
|
||||
"path": "../ui-context-menu"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Web application bundle mounts this Client plugin. External Client plugins declare the package in `dsh.client.inject`, import its `/client` types, and inject the `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.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 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.
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 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
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 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.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -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)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
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` 将数据操作放在这些行之后;普通操作排在之前。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
注册表将查询和回调生命周期与展示分离。共享覆盖层复用现有 Menu 原语,处理键盘移动、焦点返回、关闭、子菜单和视口定位。[注册表](src/client/registry.ts) 管理取消与排序;[目标和操作类型](src/client/types.ts) 定义公开字段。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
- [Slots](../ui-slots/README.zh.md) — 组件贡献。
|
||||
- [文件操作](../ui-file-actions/README.zh.md) — 原始内容传输。
|
||||
- [本地打开](../ui-open-in-app/README.zh.md) — 内置资源操作。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无,因为弹出菜单展示与注册不会构造模型输入。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;贡献操作所属功能负责后续可能产生的模型交互。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 内置渲染器是共享 Web Menu,Desktop 也使用它;未提供原生菜单渲染器。
|
||||
- 仅有组件形式的旧操作不会出现在纯数据查询 API 中。
|
||||
- 菜单是窗口内的临时状态,不持久化到会话数据。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作背景 — 点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Kontextmenüs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Context menus"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Menús contextuales"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Menus contextuels"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user