diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 9d992637b1..45b58e1d36 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: 6a8ebfcf1fd7be22a5bda5a209d0fdc3586931bb -2026-09-08-present-workspace-source-files.zh.md: d9361f2281eeb027e4c44b84c7f01af1639b66f2 +2026-09-08-present-workspace-source-files.md: 525a470d09cb5cf1d5bdbeef26b2414e31e900c9 +2026-09-08-present-workspace-source-files.zh.md: 3d969d0f4488b19f4c62d4d2e867b84519000f0a diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index 6a8ebfcf1f..525a470d09 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -18,6 +18,8 @@ The tool remains an ordinary package with shared filesystem and tool error class An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. +File cards separate default-app opening from file-manager navigation. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each gesture. A split button preserves one-click default-app opening while keeping the folder action explicit. + ## Alternatives considered **Immutable attachment snapshots and editable temporary copies** preserve delivered versions after source edits or deletion, but make desktop edits diverge from workspace files and introduce retention work without a current product requirement. This decision supersedes the [snapshot-delivery design](../../archived/feature/2026-09-08-web-explicit-file-delivery.md). Neither a download endpoint nor a fallback copy remains; both require an explicit future product decision. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index d9361f2281..3d969d0f44 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -18,6 +18,8 @@ Status: implemented 经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 +文件卡片区分默认应用打开与文件管理器导航。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次操作时执行配置的可用性检查。分段按钮保留一键默认应用打开,同时提供明确的文件夹操作。 + ## 考虑过的替代方案 **不可变附件快照和可编辑临时副本**可在源文件编辑或删除后保留交付版本,但会使桌面编辑与工作区文件分离,并在缺少当前产品需求时引入保留工作。本决策取代[快照交付设计](../../archived/feature/2026-09-08-web-explicit-file-delivery.md)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。 diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 52aee329fa..802e5eed1d 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -35,7 +35,7 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const events: SessionEvent[] = [] let nativeRoot: string | undefined let openLog: string - const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string }) + const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string | null; action: 'open' | 'reveal' }) const downloads: string[] = [] beforeAll(async () => { @@ -46,11 +46,14 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!${process.execPath} const fs = require('node:fs'); -fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n'); +const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, content: action === 'open' ? fs.readFileSync(path, 'utf8') : null }) + '\\n'); `, { mode: 0o700 }) vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) @@ -118,7 +121,14 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button').count()).toBe(2) + expect(await row.getByRole('button').count()).toBe(4) + const beforeReveal = (await opened()).length + await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') + await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() + expect((await revealResponse).status()).toBe(204) + await expect.poll(opened).toHaveLength(beforeReveal + 1) + expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') @@ -126,7 +136,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes }) } } const count = (await opened()).length @@ -135,7 +145,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect((await openedResponse).status()).toBe(204) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) expect(response.status()).toBe(200) diff --git a/apps/web/tests/present.overlay.yml b/apps/web/tests/present.overlay.yml new file mode 100644 index 0000000000..a39a66dad9 --- /dev/null +++ b/apps/web/tests/present.overlay.yml @@ -0,0 +1,4 @@ +# Native commands are owned fixtures; desktop availability must be identical in headless CI. +- id: session-controller + config: + nativeOpen: true diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 26fee2e323..e8789c3d41 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: de0930ea7effcba69bc1f9a4dd405ceda23919d7 -session.zh.md: ec15dbdec3a8887d8fa87c9698b8ad14699a534b +session.md: 8b4da0f7969fad652ac6ea2288746198f9813954 +session.zh.md: ae554ebaf620d5182804cf4604498d843dbeadaf diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index de0930ea7e..8b4da0f796 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -680,7 +680,7 @@ The backends that consume this contract are on [persistence.md](persistence.md). `ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections. -`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. +`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. @@ -754,6 +754,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise @@ -758,6 +758,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise Promise + /** Native file-manager handoff. */ + readonly revealPath?: (path: string, signal: AbortSignal) => Promise /** Native handoff availability probe. */ readonly canOpenPath?: () => boolean } @@ -105,6 +108,7 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() @@ -132,6 +136,7 @@ export class SessionController extends TypertRemoteService { this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx) this.openPath = internals.openPath ?? openNativePath + this.revealPath = internals.revealPath ?? revealNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) @@ -268,6 +273,15 @@ export class SessionController extends TypertRemoteService { return this.canOpenPath() } + /** + * Describe the serving desktop for authenticated file-action routes. + * @returns Host name, configured availability, and platform-specific file-manager behavior. + */ + workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null } { + const fileManager = nativeFileManager() + return { name: hostname(), available: fileManager !== null && this.canOpenPath(), fileManager } + } + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. @@ -289,7 +303,8 @@ export class SessionController extends TypertRemoteService { } signal.throwIfAborted() try { - await this.openPath(request.path, signal) + if (request.action === 'reveal') await this.revealPath(request.path, signal) + else await this.openPath(request.path, signal) return { opened: true } } catch (error: unknown) { if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 607baed3cf..60f7a93e56 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -358,6 +358,8 @@ export interface SessionCancelValue { /** Request to open one path prepared by a Session-aware caller on the Host desktop. */ export interface SessionOpenWorkspacePathRequest { + /** File-manager navigation when requested; omission uses the default application. */ + readonly action?: 'reveal' /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ readonly path: string } diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index ee199ddcdd..eb789cb374 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -1,3 +1,4 @@ +import * as nativeCommand from '@deepseek-ai/dsh-native-command' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' @@ -138,3 +139,34 @@ describe('session/openWorkspacePath', () => { }) }) }) + + +it('reports Host file-manager metadata and dispatches reveal separately from default-app open', async () => { + const ctx = await context() + const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, + }) + try { + expect(controller.workspaceDesktop()).toMatchObject({ available: true, name: expect.any(String) as string }) + const signal = new AbortController().signal + await controller.openWorkspacePath({ path: '/workspace/report.txt', action: 'reveal' }, signal) + expect(revealPath).toHaveBeenCalledWith('/workspace/report.txt', signal) + expect(openPath).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) + +it('uses the native reveal adapter without a test override and respects unsupported desktop metadata', async () => { + const ctx = await context() + const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined) + const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null) + try { + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, + }) + expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null }) + await controller.openWorkspacePath({ path: '/report.txt', action: 'reveal' }, new AbortController().signal) + expect(reveal).toHaveBeenCalledOnce() + } finally { manager.mockRestore(); reveal.mockRestore(); await ctx.fiber.dispose() } +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index ac992baced..721288f7dc 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -91,6 +91,7 @@ export interface TestSessionRemoteDefaults { readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } @@ -284,6 +285,7 @@ function installControllers( }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, ) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index a72595b84a..654494e779 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 4886a5817e6118ef76d5e7b49ed7fe3ed319650f -README.zh.md: 43c68d4563f9ec32bb15fdd3df9a2e21225ebc13 +README.md: 3196202a053ae35bb3055666a680c9d6cfc2a2f8 +README.zh.md: bb394ae756820d7b536ea9326c0837cf59a85a01 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 4886a5817e..3196202a05 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, workspace paths, and a split Open button. Its primary action opens the source in the Host’s default application; the menu offers Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. The menu names the serving Host, independently of the browser’s operating system. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Both actions share pending state and show progress, acknowledgement, or an action-specific retryable error. A missing desktop disables both actions; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row @@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching either native action. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 43c68d4563..bb394ae756 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,9 +30,9 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明、工作区路径和分段“打开”按钮。主按钮在 Host 默认应用中打开源文件;菜单在 macOS 上提供“在 Finder 中显示”,在 Windows 和 WSL 上提供“在文件资源管理器中显示”,在 Linux 上通过默认文件管理器“打开所在文件夹”。菜单显示实际提供服务的 Host 名称,不依赖浏览器的操作系统。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。Host 没有桌面时禁用两个操作;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动所选原生操作。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index 97befd2946..e76ca5dff1 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -1,14 +1,24 @@ -/** Immutable file deliveries at the end of a turn. */ +/** File metadata and joined native actions remain readable in narrow conversation columns. */ .root { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin-top: 12px; } -.label { font-size: 12px; color: var(--dsw-alias-label-secondary); } +.label { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary); } +.hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary); } .presented { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; } -.file { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 14px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); text-decoration: none; font: inherit; text-align: left; cursor: pointer; } -.file:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l3); } -.file:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; } -.fileIcon { flex: 0 0 auto; width: 24px; height: 24px; } +.file { display: flex; flex-direction: column; gap: 16px; min-width: 0; padding: 16px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 14px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); } +.heading { display: flex; align-items: center; gap: 12px; min-width: 0; } +.fileIcon { flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 46px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-link); } +.fileIcon svg { width: 22px; height: 22px; } .details { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; } +.nameRow { display: flex; align-items: center; gap: 8px; min-width: 0; } .fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } -.metadata { color: var(--dsw-alias-label-tertiary); font-size: 11px; } +.metadata { flex-shrink: 0; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-interactive-bg-hover); border-radius: 4px; padding: 1px 4px; font-size: 11px; } .description { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; } -.open { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; } -.file:disabled { cursor: wait; opacity: 0.65; } +.footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: auto; } +.path { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; } +.path svg { flex-shrink: 0; } +.path span { overflow-wrap: anywhere; } +.split { display: inline-flex; align-items: stretch; flex-shrink: 0; margin-left: auto; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 8px; } +.open { color: var(--dsw-alias-link); font-weight: 500; } +.chevron { border-left: 0.5px solid var(--dsw-alias-border-l2); } +.menuLabel { display: flex; flex-direction: column; gap: 2px; } +.menuDetail { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; } +@media (pointer: coarse) { .open, .chevron { min-height: 44px; min-width: 44px; } } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 2faf259ad4..420741512a 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,20 +1,25 @@ /** Existing changed-file chips and explicitly declared files for a closing turn. */ import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' import { ProducedFiles } from './ProducedFiles.tsx' -import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' +import { presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import { presentedFileUrl } from '../presented.ts' +import { PresentedFileCard } from './PresentedFileCard.tsx' import css from './Deliverables.module.css' interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] } /** Native-open callbacks and shared gesture status supplied by the plugin. */ export interface DeliverablesInjected { - hooks: { presentedOpen: ObservableSnapshot> } + hooks: { + presentedOpen: ObservableSnapshot> + presentedHost: ObservableSnapshot> + } + reloadPresentedHost: PresentedOpenController['loadHost'] openPresented: PresentedOpenController['open'] } @@ -34,30 +39,28 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen }: Pick & { +export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { matched: DeliverablesMatch } & PropsLocale & Pick & InjectFace) { const states = usePresentedOpen(value => value) + const host = usePresentedHost(value => value) return <> {matched.produced.length > 0 && } {matched.presented.length > 0 &&
- {t('presented.label')} +
+ {t('presented.label')} + {host !== null && host !== 'error' && {t('presented.target')}} +
+ {host === 'error' &&
+ {t('presented.hostError')} + +
} + {host !== null && host !== 'error' && !host.available && {t('presented.unavailable')}}
- {matched.presented.map((file) => { - const phase = states[presentedFileUrl(sessionId, file.seq, file.index)] - return })} + {matched.presented.map(file => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx new file mode 100644 index 0000000000..d3af07034a --- /dev/null +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -0,0 +1,71 @@ +/** File identity and explicit default-app or file-manager actions for one delivery. */ +import { useState } from 'react' +import { + Button, Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, + IconChevronDownOutline14, IconFolderOpenOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { PresentedAction, PresentedHost } from '../presented.ts' +import type { PresentedOpenPhase } from './present-open.ts' +import { basename, type PresentedPath } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './Deliverables.module.css' + +/** + * Render independent file actions without nesting buttons inside a clickable card. + * @param props - durable file metadata, Host capabilities, gesture status, and localized copy. + * @returns the file card and its anchored action menu. + */ +export function PresentedFileCard({ file, phase, host, onAction, t }: { + file: PresentedPath + phase: PresentedOpenPhase | undefined + host: PresentedHost | null + onAction: (action: PresentedAction) => void +} & PropsLocale) { + const [menuOpen, setMenuOpen] = useState(false) + const pending = phase === 'opening' || phase === 'revealing' + const disabled = pending || host === null || !host.available + const reveal = host?.fileManager ?? 'directory' + const act = (action: PresentedAction) => { setMenuOpen(false); onAction(action) } + const description = (title: string, detail: string) => + {title}{detail} + + return
+
+ +
+
+ {basename(file.path)} + {basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')} +
+ {file.description && {file.description}} +
+
+
+ {file.path} +
+ + { setMenuOpen(false) }} + anchor={} + items={[ + { type: 'label', id: 'host', text: t('presented.host', { name: host?.name ?? '' }) }, + { id: 'open', icon: , + label: description(t('presented.defaultApp'), t('presented.openDetail')) }, + { type: 'separator', id: 'separator' }, + { id: 'reveal', icon: , + label: description(t(`presented.${reveal}`), t(reveal === 'directory' ? 'presented.directoryDetail' : 'presented.revealDetail')) }, + ]} + onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> +
+
+ {phase !== undefined && + {t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`)} + } +
+} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 09156448b4..86ba4e57e4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -41,6 +41,7 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() ctx.effect(() => () => opener.dispose()) + void opener.loadHost() ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -50,8 +51,9 @@ export function apply(ctx: ClientContext): void { select: selectDeliverables, locale: NS, inject: (): DeliverablesInjected => ({ - hooks: { presentedOpen: opener.state }, - openPresented: (sessionId, seq, index) => opener.open(sessionId, seq, index), + hooks: { presentedOpen: opener.state, presentedHost: opener.host }, + reloadPresentedHost: () => opener.loadHost(), + openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), }), }, Deliverables), ) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index c206f2d23a..7dec4ffdbf 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -6,6 +6,23 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'presented.label': '交付文件', + 'presented.revealError': '无法打开所在文件夹,请重试', + 'presented.directoryOpened': '已请求打开所在文件夹', + 'presented.revealed': '已请求在文件管理器中显示', + 'presented.revealing': '正在打开所在文件夹…', + 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', + 'presented.retry': '重试', + 'presented.hostError': '无法读取主机桌面信息', + 'presented.host': '操作主机:{name}', + 'presented.target': '在 DSH 主机上打开', + 'presented.directoryDetail': '使用系统默认文件管理器', + 'presented.revealDetail': '打开所在文件夹并选中文件', + 'presented.directory': '打开所在文件夹', + 'presented.explorer': '在文件资源管理器中显示', + 'presented.finder': '在 Finder 中显示', + 'presented.openDetail': '打开此文件', + 'presented.defaultApp': '用默认应用打开', + 'presented.more': '{name} 的更多文件操作', 'presented.action': '打开', 'presented.opening': '正在打开…', 'presented.opened': '已在默认程序中打开', @@ -27,6 +44,23 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { 'presented.label': 'Deliverables', + 'presented.revealError': 'Could not open containing folder. Try again.', + 'presented.directoryOpened': 'Requested opening containing folder', + 'presented.revealed': 'Requested display in file manager', + 'presented.revealing': 'Opening containing folder…', + 'presented.unavailable': 'This Host has no desktop available to open files or folders', + 'presented.retry': 'Retry', + 'presented.hostError': 'Could not read the Host desktop information', + 'presented.host': 'Opens on {name}', + 'presented.target': 'Opens on the DSH Host', + 'presented.directoryDetail': 'Use the system default file manager', + 'presented.revealDetail': 'Open its folder and select the file', + 'presented.directory': 'Open containing folder', + 'presented.explorer': 'Show in File Explorer', + 'presented.finder': 'Show in Finder', + 'presented.openDetail': 'Open this file', + 'presented.defaultApp': 'Open in default app', + 'presented.more': 'More file actions for {name}', 'presented.action': 'Open', 'presented.opening': 'Opening…', 'presented.opened': 'Opened in default app', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 5b49941a57..8d74c1615f 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -1,15 +1,18 @@ /** Shared native-open status for delivery cards and closing-message file mentions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { presentedFileUrl } from '../presented.ts' +import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ -export type PresentedOpenPhase = 'opening' | 'opened' | 'error' +export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ export class PresentedOpenController { /** File action URLs key the state across Sessions, turns, and both clickable surfaces. */ readonly state = createSnapshotStore>({}) + /** Native destination metadata, or a retryable read failure. */ + readonly host = createSnapshotStore(null) + private loading: Promise | undefined private readonly lifetime = new AbortController() private readonly pending = new Set>() @@ -19,13 +22,15 @@ export class PresentedOpenController { * @param sessionId - viewed Session, including a fork's own identity. * @param seq - durable delivery event sequence. * @param index - original file index within that event. + * @param action - default application open or file-manager reveal. * @returns after the Host acknowledges opening or the error state is published. */ - async open(sessionId: SessionId, seq: number, index: number): Promise { + async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise { const url = presentedFileUrl(sessionId, seq, index) - if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return - this.state.update((state) => { state[url] = 'opening' }) - const task = this.request(url) + const phase = this.state.getSnapshot()[url] + if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return + this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) + const task = this.request(url, action) this.pending.add(task) try { await task @@ -34,20 +39,50 @@ export class PresentedOpenController { } } + /** + * Read the serving desktop metadata, coalescing concurrent reads; a later call retries failure. + * @returns after metadata or a retryable error is published. + */ + async loadHost(): Promise { + if (this.lifetime.signal.aborted) return + if (this.loading !== undefined) return this.loading + this.host.set(null) + const task = this.readHost() + this.loading = task + this.pending.add(task) + try { await task } + finally { this.loading = undefined; this.pending.delete(task) } + } + + private async readHost(): Promise { + let host: PresentedHost | 'error' = 'error' + try { + const response = await fetch(PRESENT_HOST_PATH, { signal: this.lifetime.signal }) + if (response.ok) { + const value: unknown = await response.json() + if (isPresentedHost(value)) host = value + } + } catch { + // HTTP, JSON, and transport failures leave a retryable metadata read. + } + if (!this.lifetime.signal.aborted) this.host.set(host) + } + /** Cancel outstanding requests and wait until no request can publish state. */ async dispose(): Promise { this.lifetime.abort() await Promise.all(this.pending) } - private async request(url: string): Promise { - let phase: PresentedOpenPhase = 'opened' + private async request(url: string, action: PresentedAction): Promise { + const failure = action === 'open' ? 'error' : 'revealError' + let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' try { - const response = await fetch(url, { method: 'POST', signal: this.lifetime.signal }) - if (!response.ok) phase = 'error' + const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal }) + if (!response.ok) phase = failure } catch { // Transport failures share the retryable card state with Host open failures. - phase = 'error' + phase = failure } if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) } diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index beac77657a..ba5880a0e9 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -6,13 +6,18 @@ import type {} from '@deepseek-ai/dsh-api-session-controller' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' -import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH } from './presented.ts' +import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts' /** * Register native opening inside Connection's authentication fence. * @param ctx - Session lookup, native opener, and route lifetime. */ export function registerPresentOpen(ctx: Context): void { + ctx.connection.fetch.register({ + path: PRESENT_HOST_PATH, methods: ['GET'], requestBody: 'buffered', + fetch: () => Promise.resolve(Response.json(ctx.sessionController.workspaceDesktop() satisfies PresentedHost, + { headers: { 'cache-control': 'no-store' } })), + }) const lifetime = new AbortController() const pending = new Set>() ctx.effect(() => async () => { @@ -36,6 +41,8 @@ export function registerPresentOpen(ctx: Context): void { async function handlePresentOpen(ctx: Context, request: Request): Promise { const query = new URL(request.url).searchParams + const action = query.get('action') ?? 'open' + if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 }) const id = query.get('sessionId') const seq = query.get('seq') const index = query.get('index') @@ -45,6 +52,7 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise + return typeof host.name === 'string' && typeof host.available === 'boolean' + && (host.fileManager === null || host.fileManager === 'finder' + || host.fileManager === 'explorer' || host.fileManager === 'directory') +} + /** * Validate a file declaration read from a Session log. * @param value - decoded durable data. diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index b201895445..aa022adf4c 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -61,3 +61,58 @@ it('awaits cancellation and prevents late state publication or new requests afte await controller.open(id, 2, 1) expect(fetcher).toHaveBeenCalledOnce() }) + + +it('shares pending state across open and reveal and retries the selected action', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(reply.promise).mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const revealing = controller.open(id, 2, 1, 'reveal') + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('revealing') + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher.mock.calls[0]?.[0]).toBe(`${url}&action=reveal`) + reply.resolve(new Response(null, { status: 500 })) + await revealing + expect(controller.state.getSnapshot()[url]).toBe('revealError') + await controller.open(id, 2, 1, 'reveal') + expect(controller.state.getSnapshot()[url]).toBe('revealed') + await controller.dispose() +}) + +it.each([null, {}, { name: 'host', available: 'yes', fileManager: 'finder' }, + { name: 'host', available: true, fileManager: 'unknown' }, 'invalid json', 'http', 'network', +])('makes invalid Host metadata retryable: %j', async (value) => { + const host = { name: 'linux-host', available: true, fileManager: 'directory' } + const fetcher = vi.fn() + if (value === 'network') fetcher.mockRejectedValueOnce(new Error('offline')) + else if (value === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 })) + else if (value === 'invalid json') fetcher.mockResolvedValueOnce(new Response('bad JSON')) + else fetcher.mockResolvedValueOnce(Response.json(value)) + fetcher.mockResolvedValueOnce(Response.json(host)) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + expect(controller.host.getSnapshot()).toBe('error') + await controller.loadHost() + expect(controller.host.getSnapshot()).toEqual(host) + await controller.dispose() + await controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) +}) + +it('coalesces metadata reads and suppresses their publication after disposal', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValue(reply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const first = controller.loadHost() + const second = controller.loadHost() + expect(fetcher).toHaveBeenCalledOnce() + const disposal = controller.dispose() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + reply.resolve(Response.json({ name: 'host', available: false, fileManager: null })) + await Promise.all([first, second, disposal]) + expect(controller.host.getSnapshot()).toBeNull() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index b9319587b6..fc12bc71d3 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -36,8 +36,8 @@ async function fixture() { return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent } }) ctx.provide('sessionQuery', { readEvent } as never) - const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const })) - ctx.provide('sessionController', { openWorkspacePath: opener } as never) + const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) + ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen }) await fiber @@ -176,3 +176,26 @@ describe('Presented workspace file native open route', () => { await Promise.all([request, disposal]) }) }) + + +it('reports the serving desktop and reveals only an authorized declared source', async () => { + const { cwd, file, open, opener, handler } = await fixture() + const info = await handler.fetch(new Request('http://localhost/api/present.host')) + expect(await info.json()).toEqual({ name: 'desktop', available: true, fileManager: 'finder' }) + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(204) + expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) + expect((await open('?sessionId=owner&seq=7&index=0&action=delete')).status).toBe(400) + file.path = '..' + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(403) + expect(opener).toHaveBeenCalledOnce() +}) + +it('refuses native actions when the configured Host desktop is unavailable', async () => { + const { ctx, open, opener, handler } = await fixture() + vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: 'finder' }) + expect(await (await handler.fetch(new Request('http://localhost/api/present.host'))).json()).toMatchObject({ available: false }) + for (const action of ['open', 'reveal']) { + expect((await open(`?sessionId=owner&seq=7&index=0&action=${action}`)).status).toBe(409) + } + expect(opener).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx new file mode 100644 index 0000000000..69a31fc224 --- /dev/null +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +/** Explicit file actions preserve their destination, availability, and independent failure state. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { PresentedFileCard } from '../src/client/PresentedFileCard.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) +const props = () => ({ + file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, + host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, + phase: undefined, + onAction: vi.fn(), + t: makeTranslate(en), +}) + +it.each([ + ['finder', 'Show in Finder'], ['explorer', 'Show in File Explorer'], ['directory', 'Open containing folder'], +] as const)('uses the Host %s action and closes the menu after selection', (fileManager, label) => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + expect(view.getByText('Opens on remote-desktop')).toBeTruthy() + fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) })) + expect(p.onAction).toHaveBeenCalledWith('reveal') + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in default app' })) + expect(p.onAction).toHaveBeenLastCalledWith('open') + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ })) + expect(p.onAction).toHaveBeenCalledTimes(3) +}) + +it('dismisses the menu with Escape or an outside click without launching anything', () => { + const p = props() + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(view.queryByRole('menu')).toBeNull() + expect(p.onAction).not.toHaveBeenCalled() +}) + +it.each(['opening', 'revealing'] as const)('disables both gestures while %s', (phase) => { + const p = props() + const view = render() + for (const button of view.getAllByRole('button')) { + expect((button as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(button) + } + expect(p.onAction).not.toHaveBeenCalled() +}) + +it('keeps actions disabled until a desktop is available', () => { + const p = props() + const view = render() + expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) + view.rerender() + expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) +}) + +it('localizes reveal failures and accurately reports a directory-only action', () => { + const p = props() + const view = render() + expect(view.getByRole('status').textContent).toBe(zh['presented.revealError']) + view.rerender() + expect(view.getByRole('status').textContent).toBe(en['presented.directoryOpened']) + view.rerender() + expect(view.getByRole('status').textContent).toBe(en['presented.revealed']) +}) + + +it('supports keyboard selection and returns focus to the trigger on Escape', () => { + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'End' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Home' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(document.activeElement).toBe(trigger) +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 15c95017f3..a2773f6dab 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -34,7 +34,11 @@ import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' function openProps(controller = new PresentedOpenController()) { + controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) return { + reloadPresentedHost: vi.fn(() => controller.loadHost()), + usePresentedHost: (select: (state: ReturnType) => T): T => + select(controller.host.getSnapshot()), openPresented: vi.fn((...args: Parameters) => controller.open(...args)), usePresentedOpen: (select: (state: ReturnType) => T): T => select(controller.state.getSnapshot()), @@ -549,6 +553,7 @@ describe('plugin registration', () => { service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + await face.reloadPresentedHost() await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. @@ -591,10 +596,10 @@ describe('presented files', () => { const props = openProps() props.openPresented.mockResolvedValue(undefined) const view = render() - expect(view.getAllByRole('button')).toHaveLength(8) + expect(view.getAllByRole('button')).toHaveLength(16) expect(view.queryByRole('link')).toBeNull() fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) - expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') expect(view.queryByText('Files changed')).toBeNull() }) }) @@ -629,7 +634,7 @@ it('shows file metadata and descriptions without hiding extensionless deliveries expect(view.getByText('Quarterly summary')).toBeTruthy() expect(view.getByText('TXT')).toBeTruthy() expect(view.getByText('File')).toBeTruthy() - expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app') + expect(view.getByText('out/report.txt')).toBeTruthy() }) @@ -641,5 +646,20 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits { path: 'report.txt', seq: 2, index: 0 }, ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) - expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening') + expect((view.getByRole('button', { name: 'Open report.txt in default app' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') +}) + + +it('explains a missing desktop and retries failed Host metadata', () => { + const controller = new PresentedOpenController() + const props = openProps(controller) + const matched = { produced: [], presented: [{ path: 'file.txt', seq: 2, index: 0 }] } + controller.host.set('error') + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + props.reloadPresentedHost.mockResolvedValue(undefined) + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + expect(props.reloadPresentedHost).toHaveBeenCalledOnce() + controller.host.set({ name: 'server', available: false, fileManager: null }) + view.rerender( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByText(en['presented.unavailable'])).toBeTruthy() }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 138d94440f..fc91ac631b 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 -README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 +README.md: 194fc2a3ac97a8b40bb6c385af3b4ee6b3930b52 +README.zh.md: e2dcea7a7c03556fef789db21cef8187c8b72d66 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8fd59c5ecc..194fc2a3ac 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and restores the trigger on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2478330825..e2dcea7a7c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时将焦点还给触发按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 9fa1712a66..b8efa4391d 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -77,3 +77,6 @@ align-items: center; justify-content: center; } + +.start { border-radius: 8px 0 0 8px; } +.end { border-radius: 0 8px 8px 0; } diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index d2e39dbf23..9519388d4d 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -12,18 +12,20 @@ export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' * Render a button. * @param props.variant - visual family (default 'ghost'). * @param props.size - 'md' 36px capsule (figma Button) or 'sm' 28px compact. + * @param props.segment - joined button edge within an owner-rendered split control. * @param props.icon - optional leading 16px icon node. * @returns the button element; native button attributes pass through. */ -export function Button({ variant = 'ghost', size = 'md', icon, className, children, ...rest }: { +export function Button({ variant = 'ghost', size = 'md', icon, segment, className, children, ...rest }: { variant?: ButtonVariant size?: 'md' | 'sm' + segment?: 'start' | 'end' icon?: ReactNode className?: string | undefined children?: ReactNode } & ButtonHTMLAttributes) { return ( - diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 3130086777..ed19fbb70c 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -48,6 +48,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Render an anchored dropdown menu. + * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape restores the trigger. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. @@ -81,8 +82,9 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * crowds the cell). * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, selection = 'check', getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className }: { open: boolean + autoFocus?: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] @@ -159,6 +161,10 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o } }, [open, portal, align, side, getAnchorRect]) + useEffect(() => { + if (open && autoFocus) listRef.current?.querySelector('button:not(:disabled)')?.focus() + }, [open, autoFocus]) + useEffect(() => { if (!open) { setOpenSubmenuId(null) @@ -172,7 +178,18 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o onClose() } const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape') { + onClose() + if (autoFocus) rootRef.current?.querySelector('button')?.focus() + } + if (!autoFocus || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return + const buttons = Array.from(listRef.current?.querySelectorAll('button:not(:disabled)') ?? []) + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (index < 0) return + e.preventDefault() + const next = e.key === 'Home' ? 0 : e.key === 'End' ? buttons.length - 1 + : (index + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length + buttons[next]?.focus() } document.addEventListener('pointerdown', onPointerDown) document.addEventListener('keydown', onKeyDown) @@ -180,7 +197,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o document.removeEventListener('pointerdown', onPointerDown) document.removeEventListener('keydown', onKeyDown) } - }, [open, onClose]) + }, [open, onClose, autoFocus]) // A close from selection/Escape/outside click outruns a pending grace close; // left armed it would shut a list reopened inside the grace window. Its own diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4606b99848..1e3ce94363 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1493,6 +1493,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'true when the matching open operation is available.', }, + { + signature: 'workspaceDesktop(): { name: string; available: boolean; fileManager: \'finder\' | \'explorer\' | \'directory\' | null }', + description: 'Describe the serving desktop for authenticated file-action routes.', + parameters: [], + returns: 'Host name, configured availability, and platform-specific file-manager behavior.', + }, { signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise', description: 'Open one path prepared by a Session-aware caller on the Host desktop.', @@ -5266,7 +5272,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionOpenWorkspacePathRequest', - declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}', + declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly action?: \'reveal\';\n readonly path: string;\n}', }, { name: 'SessionOpenWorkspacePathValue', diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 46c49a2810..5dba1e1698 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md -README.md: 06baa6d631198e42c6de3e20c8a0bd562564b779 -README.zh.md: 6df6ee25e32e5f7290af1c4af580fe108a0d3b9f +README.md: b127c1f03fc91282cd2a847af84f8183f147f9ac +README.zh.md: 7ff612fe9bead184ace2b5ee2c54700c8d673df0 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 06baa6d631..b127c1f03f 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -47,6 +47,8 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. +`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. + ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 6df6ee25e3..7ff612fe9b 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -47,6 +47,8 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 +`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 + ----- diff --git a/packages/util/native-command/src/index.ts b/packages/util/native-command/src/index.ts index 14100af799..a2e23a35be 100644 --- a/packages/util/native-command/src/index.ts +++ b/packages/util/native-command/src/index.ts @@ -7,10 +7,13 @@ export { runNativeCommand } from './runner.ts' export type { NativeCommandRunner } from './runner.ts' export { canOpenNativePath, + nativeFileManager, + revealNativePath, openNativePath, openNativeTextFile, } from './path-opener.ts' export type { + NativeFileManager, PathOpenerInternals, PathOpenerRunner, } from './path-opener.ts' diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 97d2084e61..0d8dcb32da 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -10,7 +10,7 @@ */ import { release as osRelease } from 'node:os' -import { extname } from 'node:path' +import { dirname, extname } from 'node:path' import { runNativeCommand, type NativeCommandRunner } from './runner.ts' /** Testable command boundary; native implementations never invoke a shell. */ @@ -201,3 +201,54 @@ export function openNativeTextFile( ): Promise { return openNativePathWithIntent(path, signal, 'text-editor', internals) } + +/** File-manager behavior available on the serving Host, including WSL's Windows desktop. */ +export type NativeFileManager = 'finder' | 'explorer' | 'directory' + +/** + * Identify the native file-manager action without inspecting the browser's platform. + * @param internals - platform and WSL facts. + * @returns the supported file-manager action, or null on unsupported platforms. + */ +export function nativeFileManager(internals: PathOpenerInternals = {}): NativeFileManager | null { + const platform = internals.platform ?? process.platform + if (platform === 'darwin') return 'finder' + if (platform === 'win32' || (platform === 'linux' && isWsl(internals))) return 'explorer' + return platform === 'linux' ? 'directory' : null +} + +/** + * Reveal a file in Finder or Explorer, or open its parent in the Linux default file manager. + * @param path - absolute file path already authorized by the caller. + * @param signal - caller lifetime; abort terminates the native command. + * @param internals - platform, environment, and command runner for adapter tests. + * @returns after the file-manager command accepts the request; launch failures reject. + */ +export async function revealNativePath( + path: string, signal: AbortSignal, internals: PathOpenerInternals = {}, +): Promise { + signal.throwIfAborted() + const platform = internals.platform ?? process.platform + const run = internals.run ?? runNativeCommand + const manager = nativeFileManager({ ...internals, platform }) + if (manager === 'finder') { + await run('open', ['-R', path], signal) + return + } + if (manager === 'explorer') { + let windowsPath = path + if (platform === 'linux') { + const translated = await run('wslpath', ['-w', path], signal) + signal.throwIfAborted() + windowsPath = translated.stdout.replace(/[\r\n]+$/, '') + if (windowsPath === '') throw new Error('wslpath returned no Windows path') + } + await run('explorer.exe', [`/select,${windowsPath}`], signal) + return + } + if (manager === 'directory') { + await run('xdg-open', [dirname(path)], signal) + return + } + throw new Error(`native file manager is unsupported on ${platform}`) +} diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index d5cd1bb751..347588095d 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -17,7 +17,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { release as osRelease } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts' +import { canOpenNativePath, nativeFileManager, revealNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts' const signal = () => new AbortController().signal @@ -326,3 +326,55 @@ describe('canOpenNativePath', () => { expect(canOpenNativePath()).toBe(canOpenNativePath({ platform: process.platform })) }) }) + + +describe('native file manager', () => { + it.each([ + ['darwin', 'finder', '/tmp/my report.txt', 'open', ['-R', '/tmp/my report.txt']], + ['win32', 'explorer', 'C:\\work\\my report.txt', 'explorer.exe', ['/select,C:\\work\\my report.txt']], + ['linux', 'directory', '/tmp/a $b; report.txt', 'xdg-open', ['/tmp']], + ] as const)('reveals through %s without opening the file association', async (platform, manager, path, command, args) => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + const internals = { platform, env: {}, osRelease: 'generic', run } + expect(nativeFileManager(internals)).toBe(manager) + await revealNativePath(path, signal(), internals) + expect(run).toHaveBeenCalledExactlyOnceWith(command, args, expect.any(AbortSignal)) + }) + + it('selects a translated WSL path in Explorer and never starts a Linux file manager', async () => { + const run = vi.fn(async () => ({ stdout: 'C:\\work\\报告.txt\r\n', stderr: '' })) + const internals = { platform: 'linux' as const, env: { WSL_DISTRO_NAME: 'Ubuntu' }, run } + expect(nativeFileManager(internals)).toBe('explorer') + await revealNativePath('/mnt/c/work/报告.txt', signal(), internals) + expect(run.mock.calls.map(([cmd, args]) => [cmd, args])).toEqual([ + ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,C:\\work\\报告.txt']], + ]) + }) + + it('refuses empty WSL translations and cancelled translation without launching Explorer', async () => { + const abort = new AbortController() + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + const internals = { platform: 'linux' as const, env: {}, osRelease: 'microsoft', run } + await expect(revealNativePath('/file', signal(), internals)).rejects.toThrow('no Windows path') + run.mockImplementationOnce(async () => { abort.abort(new Error('stopped')); return { stdout: 'C:\\file', stderr: '' } }) + await expect(revealNativePath('/file', abort.signal, internals)).rejects.toThrow('stopped') + expect(run.mock.calls.every(([cmd]) => cmd === 'wslpath')).toBe(true) + }) + + it('rejects unsupported platforms, cancellation, and launcher errors', async () => { + const run = vi.fn().mockRejectedValue(new Error('desktop failed')) + expect(nativeFileManager({ platform: 'aix' })).toBeNull() + await expect(revealNativePath('/file', signal(), { platform: 'aix', run })).rejects.toThrow('unsupported') + await expect(revealNativePath('/file', AbortSignal.abort(new Error('cancelled')), { run })).rejects.toThrow('cancelled') + expect(run).not.toHaveBeenCalled() + await expect(revealNativePath('/file', signal(), { platform: 'darwin', run })).rejects.toThrow('desktop failed') + expect(nativeFileManager()).toBe(process.platform === 'darwin' ? 'finder' : process.platform === 'win32' ? 'explorer' : 'directory') + }) +}) + + +it('uses the native runner for a file-manager handoff when none is injected', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { callback(null, '', '') }) + await revealNativePath('/tmp/report.txt', signal()) + expect(execFileMock).toHaveBeenCalled() +}) diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 1a79b00463..1346420ed9 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -63,17 +63,24 @@ - code: AFTER_PRESENT - text: — no retries, no extra files. - paragraph: PRESENT_DONE -- text: Deliverables +- text: Deliverables Opens on the DSH Host report.txt TXT delivered report +- img +- text: report.txt - button "Open report.txt in default app": - - text: report.txt TXT delivered report - - status: Opened in default app - img - text: Open +- button "More file actions for report.txt": + - img +- status: Opened in default app +- text: 说明.txt TXT delivered note +- img +- text: 说明.txt - button "Open 说明.txt in default app": - - text: 说明.txt TXT delivered note - - status: Opened in default app - img - text: Open +- button "More file actions for 说明.txt": + - img +- status: Opened in default app - button "Copy": - img - button "Good response":