Tool-row file-open failures stay visible
Tool-row file-open failures stay visible
Tool-row path clicks already call `host.openPath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try. The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.md) still owns the link
English
Problem
Tool-row path clicks already call host.openPath through the chat view's injected openFile. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try.
The file-open-in-OS decision still owns the link gesture and the Host handoff. This note owns only the refusal.
Decision
The inject returns the workspaces.openPath promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or the unknown-open fallback when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog.
The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. The produced-files folder action opens ., and that refusal uses the folder title and unknown-open copy.
The Host message is shown as thrown. WorkspaceRuntime.openPath prefixes path open failed: onto the wire error; the dialog does not unwrap that prefix.
Alternatives considered
- Per-row inline error. The Host call is conversation-owned and several entries share one opener; a row-local banner would duplicate the same refusal next to every click target.
- Toast without retry. The product ask is the reason and a retry entry. The workspace folder-adoption dialog already pairs those two.
- Chat-store remount persistence. A failed open is transient view state. The chat store survives view remounts, so a leftover dialog would return after a tab switch that cannot usefully retry the original gesture.
Consequences
A silent Host refusal is no longer a success from the reader's seat. Headless or remote deployments that click a path now see why the desktop handoff did not happen. The view holds one extra request-generation counter so dismiss and retry stay race-safe.
Testing
Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. apps/web/tests/seeded-history.e2e.ts stubs host.openPath to fail over a cold-resumed read row, pins the assembled dialog in file-open-failure.expected.md, and asserts the English reason plus a second call with the same payload.
中文
问题
工具行路径点击已经通过聊天视图注入的 openFile 调用 host.openPath。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。
用系统应用打开文件的决策 仍然拥有链接手势和 Host 交接。本 Agent Note 只拥有拒绝路径。
决策
inject 返回 workspaces.openPath 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用未知打开回退文案),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。
对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 .,该拒绝使用文件夹标题和未知打开回退文案。
Host 消息按抛出内容展示。WorkspaceRuntime.openPath 会在 wire 错误前加上 path open failed: 前缀;对话框不拆掉该前缀。
考虑过的替代方案
- 按行内联错误。 Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。
- 没有重试的 toast。 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。
- 写入 chat store 并跨 remount 保留。 打开失败是瞬时视图状态。chat store 会在视图 remount 后存活,于是残留对话框会在无法有效重试原手势的页签切换之后回来。
后果
从读者一侧看,静默的 Host 拒绝不再等同于成功。无头或远程部署点击路径时,能看到桌面交接为何没有发生。视图多持有一个请求世代计数器,使关闭与重试在竞态下仍然安全。
测试
包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。apps/web/tests/seeded-history.e2e.ts 在冷恢复的 read 行上把 host.openPath stub 为失败,用 file-open-failure.expected.md 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。