feat(web): add native file actions to artifact cards

This commit is contained in:
yudshj
2026-09-09 15:26:28 +08:00
committed by imccyu
parent da042b78ff
commit 4158a96e14
44 changed files with 702 additions and 94 deletions
@@ -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
@@ -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.
@@ -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)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。
+15 -5
View File
@@ -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<Array<{ path: string; content: string }>> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string })
const opened = async (): Promise<Array<{ path: string; content: string | null; action: 'open' | 'reveal' }>> => (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)
+4
View File
@@ -0,0 +1,4 @@
# Native commands are owned fixtures; desktop availability must be identical in headless CI.
- id: session-controller
config:
nativeOpen: true
+2 -2
View File
@@ -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
+7 -1
View File
@@ -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.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -754,6 +754,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
*/
@Remote canOpenWorkspacePath(): boolean
/**
* 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 }
/**
* Open one path prepared by a Session-aware caller on the Host desktop.
* @param request - path after best-effort Session workspace resolution.
+7 -1
View File
@@ -684,7 +684,7 @@ interface TurnEndReasonMap {
`ModelCatalog` 是 `session/modelCatalog` 返回的 Host generation 模型目录:它携带部署默认值、可路由 provider id、成功的 provider 分组与相互隔离的 provider 失败。它不由某个 Session 派生,因此与 Session projection 分开保存。
`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。
`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 可选的 `action: "reveal"` 选择文件管理器导航;省略时使用默认应用打开。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -758,6 +758,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
*/
@Remote canOpenWorkspacePath(): boolean
/**
* 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 }
/**
* Open one path prepared by a Session-aware caller on the Host desktop.
* @param request - path after best-effort Session workspace resolution.
@@ -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/api/session-controller/README.md
README.md: ef6ffb35636f9f1a87c3b30832f68540d5dcd0ea
README.zh.md: 97949e01379446e6171debb9fb1250e8088413f8
README.md: 24cfbba626ddec510452e3b6c8bc4333c03876bd
README.zh.md: c90357c60eab9427f5656b48c4ea99252d87e2bf
+1 -1
View File
@@ -28,7 +28,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provenance, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame.Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
+1 -1
View File
@@ -28,7 +28,7 @@ kind: "package-reference"
Client journal 在发布 follow snapshot、live entry 或历史页之前验证精确的 V3 event envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 来源、request header 可选字段的省略规则以及 tool error 一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 会在解析 Agent 或追加 Session event 前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 会在解析 Agent 或追加 Session event 前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 经过认证的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace``prepend``append``settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq``nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered framereplacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message``assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
+17 -2
View File
@@ -1,10 +1,11 @@
/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
import { hostname } from 'node:os'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-client-file-upload'
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
@@ -76,6 +77,8 @@ export interface Config {
export interface SessionControllerInternals {
/** Native default-application handoff. */
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Native file-manager handoff. */
readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
/** 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<void>
private readonly revealPath: (path: string, signal: AbortSignal) => Promise<void>
private readonly canOpenPath: () => boolean
private readonly promotions = new Set<Promise<void>>()
@@ -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', {})
@@ -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
}
@@ -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() }
})
@@ -91,6 +91,7 @@ export interface TestSessionRemoteDefaults {
readonly nativeOpen?: boolean
readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
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 },
},
)
@@ -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
+3 -3
View File
@@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th
<a id="explicit-deliveries"></a>
### 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 Hosts 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 Hosts 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 browsers 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 Sessions 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 Sessions 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.
</details>
+3 -3
View File
@@ -30,9 +30,9 @@ kind: "package-reference"
<a id="explicit-deliveries"></a>
### 显式交付
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 的工作区解析路径,检查当前文件存在且位于工作区内,再启动所选原生操作。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
</details>
@@ -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; } }
@@ -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<ReturnType<PresentedOpenController['state']['getSnapshot']>> }
hooks: {
presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>>
presentedHost: ObservableSnapshot<ReturnType<PresentedOpenController['host']['getSnapshot']>>
}
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<TurnTailOwnerProps, 'openFile'> & {
export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick<TurnTailOwnerProps, 'openFile'> & {
matched: DeliverablesMatch
} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & InjectFace<DeliverablesInjected>) {
const states = usePresentedOpen(value => value)
const host = usePresentedHost(value => value)
return <>
{matched.produced.length > 0 && <ProducedFiles matched={matched.produced} openFile={openFile} t={t} />}
{matched.presented.length > 0 && <div className={css.root}>
<span className={css.label}>{t('presented.label')}</span>
<div className={css.label}>
<span>{t('presented.label')}</span>
{host !== null && host !== 'error' && <span>{t('presented.target')}</span>}
</div>
{host === 'error' && <div className={css.hostStatus}>
<span>{t('presented.hostError')}</span>
<Button size="sm" onClick={() => { void reloadPresentedHost() }}>{t('presented.retry')}</Button>
</div>}
{host !== null && host !== 'error' && !host.available && <span className={css.hostStatus}>{t('presented.unavailable')}</span>}
<div className={css.presented} data-presented-files-row>
{matched.presented.map((file) => {
const phase = states[presentedFileUrl(sessionId, file.seq, file.index)]
return <button key={file.path} type="button" className={css.file}
disabled={phase === 'opening'}
onClick={() => { void openPresented(sessionId, file.seq, file.index) }}
title={t('presented.open', { name: file.path })} aria-label={t('presented.open', { name: file.path })}>
<LinkIcon kind={classifyLinkPath(file.path)} className={css.fileIcon} />
<span className={css.details}>
<span className={css.fileName}>{basename(file.path)}</span>
<span className={css.metadata}>{basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')}</span>
{file.description && <span className={css.description}>{file.description}</span>}
{phase !== undefined && <span className={css.description} role="status">{t(`presented.${phase}`)}</span>}
</span>
<span className={css.open}><IconRightUpOutline16 /><span>{t('presented.action')}</span></span>
</button>})}
{matched.presented.map(file => <PresentedFileCard key={file.path} file={file}
phase={states[presentedFileUrl(sessionId, file.seq, file.index)]}
host={host === 'error' ? null : host} t={t}
onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
</div>
</div>}
</>
@@ -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<typeof NS>) {
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) => <span className={css.menuLabel}>
<span>{title}</span><span className={css.menuDetail}>{detail}</span>
</span>
return <div className={css.file} data-presented-file>
<div className={css.heading}>
<span className={css.fileIcon}><LinkIcon kind={classifyLinkPath(file.path)} /></span>
<div className={css.details}>
<div className={css.nameRow}>
<span className={css.fileName}>{basename(file.path)}</span>
<span className={css.metadata}>{basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')}</span>
</div>
{file.description && <span className={css.description}>{file.description}</span>}
</div>
</div>
<div className={css.footer}>
<span className={css.path}><IconFolderOpenOutline16 /><span>{file.path}</span></span>
<div className={css.split}>
<Button segment="start" className={css.open} icon={<IconRightUpOutline16 />} disabled={disabled}
aria-label={t('presented.open', { name: file.path })} onClick={() => { act('open') }}>
{t('presented.action')}
</Button>
<Menu open={menuOpen && !disabled} autoFocus portal align="end" onClose={() => { setMenuOpen(false) }}
anchor={<Button segment="end" className={css.chevron} disabled={disabled}
aria-haspopup="menu" aria-expanded={menuOpen && !disabled}
aria-label={t('presented.more', { name: file.path })}
onClick={() => { setMenuOpen(value => !value) }}><IconChevronDownOutline14 /></Button>}
items={[
{ type: 'label', id: 'host', text: t('presented.host', { name: host?.name ?? '' }) },
{ id: 'open', icon: <IconRightUpOutline16 />,
label: description(t('presented.defaultApp'), t('presented.openDetail')) },
{ type: 'separator', id: 'separator' },
{ id: 'reveal', icon: <IconFolderOpenOutline16 />,
label: description(t(`presented.${reveal}`), t(reveal === 'directory' ? 'presented.directoryDetail' : 'presented.revealDetail')) },
]}
onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} />
</div>
</div>
{phase !== undefined && <span className={css.description} role="status">
{t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`)}
</span>}
</div>
}
@@ -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),
)
@@ -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<DeliverablesKey, string> = {
'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',
@@ -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<Record<string, PresentedOpenPhase | undefined>>({})
/** Native destination metadata, or a retryable read failure. */
readonly host = createSnapshotStore<PresentedHost | 'error' | null>(null)
private loading: Promise<void> | undefined
private readonly lifetime = new AbortController()
private readonly pending = new Set<Promise<void>>()
@@ -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<void> {
async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise<void> {
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<void> {
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<void> {
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<void> {
this.lifetime.abort()
await Promise.all(this.pending)
}
private async request(url: string): Promise<void> {
let phase: PresentedOpenPhase = 'opened'
private async request(url: string, action: PresentedAction): Promise<void> {
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 })
}
@@ -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<Promise<Response>>()
ctx.effect(() => async () => {
@@ -36,6 +41,8 @@ export function registerPresentOpen(ctx: Context): void {
async function handlePresentOpen(ctx: Context, request: Request): Promise<Response> {
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<Respon
}
try {
request.signal.throwIfAborted()
if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 })
const { session, target } = await ctx.sessionQuery.readEvent({
sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0,
}, request.signal)
@@ -59,7 +67,7 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise<Respon
}
if (!(await stat(path)).isFile()) return new Response('Presented path is not a file.', { status: 404 })
request.signal.throwIfAborted()
await ctx.sessionController.openWorkspacePath({ path }, request.signal)
await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal)
return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
} catch (error: unknown) {
request.signal.throwIfAborted()
@@ -6,6 +6,32 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
/** Authenticated POST route for opening a workspace file on the Host desktop. */
export const PRESENT_OPEN_PATH = '/api/present.open'
/** Authenticated desktop availability and destination metadata. */
export const PRESENT_HOST_PATH = '/api/present.host'
/** Native file action selected by an explicit user gesture. */
export type PresentedAction = 'open' | 'reveal'
/** Serving Host information; file-manager names never derive from the browser's OS. */
export interface PresentedHost {
name: string
available: boolean
fileManager: 'finder' | 'explorer' | 'directory' | null
}
/**
* Validate desktop metadata received over HTTP.
* @param value - decoded response.
* @returns whether all displayed and actionable fields are supported.
*/
export function isPresentedHost(value: unknown): value is PresentedHost {
if (typeof value !== 'object' || value === null) return false
const host = value as Record<string, unknown>
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.
@@ -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<Response>()
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<Response>()
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()
})
@@ -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()
})
@@ -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(<PresentedFileCard {...p} host={{ ...p.host, fileManager }} />)
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(<PresentedFileCard {...p} />)
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(<PresentedFileCard {...p} phase={phase} />)
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(<PresentedFileCard {...p} host={null} />)
expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true)
view.rerender(<PresentedFileCard {...p} host={{ ...p.host, available: false, fileManager: null }} />)
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(<PresentedFileCard {...p} phase="revealError" t={makeTranslate(zh)} />)
expect(view.getByRole('status').textContent).toBe(zh['presented.revealError'])
view.rerender(<PresentedFileCard {...p} phase="revealed" host={{ ...p.host, fileManager: 'directory' }} />)
expect(view.getByRole('status').textContent).toBe(en['presented.directoryOpened'])
view.rerender(<PresentedFileCard {...p} phase="revealed" />)
expect(view.getByRole('status').textContent).toBe(en['presented.revealed'])
})
it('supports keyboard selection and returns focus to the trigger on Escape', () => {
const view = render(<PresentedFileCard {...props()} />)
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)
})
@@ -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: <T,>(select: (state: ReturnType<typeof controller.host.getSnapshot>) => T): T =>
select(controller.host.getSnapshot()),
openPresented: vi.fn((...args: Parameters<PresentedOpenController['open']>) => controller.open(...args)),
usePresentedOpen: <T,>(select: (state: ReturnType<typeof controller.state.getSnapshot>) => 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(<Deliverables {...props} matched={matched} openFile={owner.openFile} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
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(<Deliverables {...props} matched={matched} openFile={() => {}} 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(<Deliverables {...props} matched={matched} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
expect(view.getByText(en['presented.unavailable'])).toBeTruthy()
})
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 输出
@@ -77,3 +77,6 @@
align-items: center;
justify-content: center;
}
.start { border-radius: 8px 0 0 8px; }
.end { border-radius: 0 8px 8px 0; }
+4 -2
View File
@@ -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<HTMLButtonElement>) {
return (
<button type="button" className={clsx(css.button, css[variant], css[size], className)} {...rest}>
<button type="button" className={clsx(css.button, css[variant], css[size], segment && css[segment], className)} {...rest}>
{icon != null && <span className={css.icon}>{icon}</span>}
{children}
</button>
+20 -3
View File
@@ -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<HTMLButtonElement>('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<HTMLButtonElement>('button')?.focus()
}
if (!autoFocus || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
const buttons = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>('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
@@ -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<SessionOpenWorkspacePathValue>',
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',
@@ -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
+2
View File
@@ -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.
-----
<a id="understand-the-implementation"></a>
@@ -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()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。
-----
<a id="understand-the-implementation"></a>
@@ -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'
@@ -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<void> {
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<void> {
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}`)
}
@@ -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<PathOpenerRunner>(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<PathOpenerRunner>(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<PathOpenerRunner>(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<PathOpenerRunner>().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()
})
+12 -5
View File
@@ -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":