This commit is contained in:
07akioni 2026-08-18 19:14:20 +08:00
parent 0148c9bef6
commit 2442e63360
10 changed files with 60 additions and 20 deletions

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 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md
2026-08-18-tool-row-file-open-failure.md: 8b54fe323f1ea9dba302a9cb8cbf10944ac7ea3f
2026-08-18-tool-row-file-open-failure.zh.md: a969905729b6cccb510ca85bcc698ddbc33ccb54
2026-08-18-tool-row-file-open-failure.md: e36552395b992e688fad35b3163b92c9f6189e43
2026-08-18-tool-row-file-open-failure.zh.md: 72b6026d9eb44c74f13c987996e652008f35a78e

View file

@ -12,9 +12,9 @@ The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.m
## 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 `fileOpen.unknown` 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 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 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.
@ -22,7 +22,7 @@ The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path
- **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 persistence.** A failed open is transient view state. The chat store survives reload, so a leftover dialog would return after a refresh that cannot usefully retry the original gesture.
- **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
@ -30,4 +30,4 @@ A silent Host refusal is no longer a success from the reader's seat. Headless or
## Testing
Package specs cover inject rejection, the dialog copy (Error, non-Error, empty), 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 and asserts the English reason plus a second call with the same payload.
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.

View file

@ -12,9 +12,9 @@ Status: implemented
## 决策
inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用 `fileOpen.unknown`),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。
inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用未知打开回退文案),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。
对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。
对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 `.`,该拒绝使用文件夹标题和未知打开回退文案。
Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。
@ -22,7 +22,7 @@ Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错
- **按行内联错误。** Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。
- **没有重试的 toast。** 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。
- **写入 chat store 并持久化。** 打开失败是瞬时视图状态。chat store 会在刷新后存活,于是残留对话框会在无法有效重试原手势的刷新之后回来。
- **写入 chat store 并跨 remount 保留。** 打开失败是瞬时视图状态。chat store 会在视图 remount 后存活,于是残留对话框会在无法有效重试原手势的页签切换之后回来。
## 后果
@ -30,4 +30,4 @@ Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错
## 测试
包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,并断言英文原因以及对同一 payload 的第二次调用。
包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。

View file

@ -33,6 +33,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expecte
// Command-row goldens over the same conversation after direct host commands.
const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url))
const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/feedback-row.expected.md', import.meta.url))
const FILE_OPEN_FAILURE_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/file-open-failure.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'seeded-history-web-e2e'
@ -427,6 +428,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
await fileLink.click()
const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' })
await dialog.waitFor({ timeout: 5_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(FILE_OPEN_FAILURE_EXPECTED, snapshot, MODE)
await expect.poll(() => dialog.innerText(), { timeout: 5_000 })
.toContain('path open failed: xdg-open is not available')
await page.getByRole('button', { name: 'Retry' }).click()
@ -544,6 +547,6 @@ describe('web e2e: seeded history renders through cold resume', () => {
// stream would have failed the turn loudly. Cleanliness pins the wire.
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'seed.jsonl', 'ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'seed.jsonl', 'ui.expected.md'])
})
})

View file

@ -0,0 +1,7 @@
- dialog "Couldn’t open file":
- heading "Couldn’t open file" [level=2]
- button "Close":
- img
- paragraph: path open failed: xdg-open is not available
- button "Cancel"
- button "Retry"

View file

@ -102,6 +102,11 @@ function openFailureMessage(error: unknown, fallback: string): string {
return message === '' ? fallback : message
}
/** ProducedFiles opens the session workspace as `.`. */
function isFolderOpenPath(path: string): boolean {
return path === '.'
}
function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
let latest: number | null = null
for (const turn of timeline.turns.values()) {
@ -175,7 +180,7 @@ export function ChatView({
const requestOpenFile = useCallback((path: string) => {
const id = ++fileOpenRequest.current
setFileOpenBusy(true)
void Promise.resolve(openFile(path)).then(
void openFile(path).then(
() => {
if (id !== fileOpenRequest.current) return
setFileOpenError(null)
@ -183,7 +188,13 @@ export function ChatView({
},
(error: unknown) => {
if (id !== fileOpenRequest.current) return
setFileOpenError({ path, message: openFailureMessage(error, t('fileOpen.unknown')) })
setFileOpenError({
path,
message: openFailureMessage(
error,
t(isFolderOpenPath(path) ? 'fileOpen.folderUnknown' : 'fileOpen.unknown'),
),
})
setFileOpenBusy(false)
},
)
@ -468,6 +479,7 @@ export function ChatView({
</div>
{fileOpenError !== null && (
<FileOpenErrorDialog
path={fileOpenError.path}
message={fileOpenError.message}
busy={fileOpenBusy}
onClose={closeFileOpenError}
@ -481,8 +493,9 @@ export function ChatView({
/** In-page Host open-path refusal: the wire reason plus a retry of the same path. */
function FileOpenErrorDialog({
message, busy, onClose, onRetry, t,
path, message, busy, onClose, onRetry, t,
}: {
path: string
message: string
busy: boolean
onClose: () => void
@ -494,7 +507,7 @@ function FileOpenErrorDialog({
open
onClose={onClose}
closeLabel={t('close')}
title={t('fileOpen.title')}
title={t(isFolderOpenPath(path) ? 'fileOpen.folderTitle' : 'fileOpen.title')}
description={message}
footer={(
<>

View file

@ -714,10 +714,11 @@ export interface ChatViewInjected {
openDetails: (target: SelectionTarget) => void
/**
* Open a tool-arg filesystem path with the host OS default application
* (relative paths resolve against the session cwd). Rejects when the Host
* cannot hand the path off; the chat view shows that reason and a retry.
* (relative paths resolve against the session cwd). Always returns a
* promise: fulfills when the Host opens the path, rejects when it cannot
* hand the path off (the chat view shows that reason and a retry).
*/
openFile: (path: string) => void | Promise<void>
openFile: (path: string) => Promise<void>
loadOlder: () => void
/** Resolve a session-authorized historical image for inline display. */
loadImage: (attachment: ImageAttachmentRef) => Promise<string>

View file

@ -94,6 +94,8 @@ export const zh = {
'chat.toBottom': '回到底部',
'fileOpen.title': '无法打开文件',
'fileOpen.unknown': '无法打开此文件',
'fileOpen.folderTitle': '无法打开文件夹',
'fileOpen.folderUnknown': '无法打开此文件夹',
'message.extraBlock': '附加内容块',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
@ -266,6 +268,8 @@ export const en = {
'chat.toBottom': 'Back to bottom',
'fileOpen.title': 'Couldn’t open file',
'fileOpen.unknown': 'Couldn’t open this file',
'fileOpen.folderTitle': 'Couldn’t open folder',
'fileOpen.folderUnknown': 'Couldn’t open this folder',
'message.extraBlock': 'Extra content block',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',

View file

@ -244,7 +244,7 @@ describe('conversation slot inject API', () => {
const b = await bench()
b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available')))
const { injected } = b.chatViewApi(ROOT)
await expect(Promise.resolve(injected.openFile('src/a.ts'))).rejects.toThrow('xdg-open is not available')
await expect(injected.openFile('src/a.ts')).rejects.toThrow('xdg-open is not available')
await b.runtime.dispose()
})

View file

@ -152,7 +152,7 @@ function emptyWorkspaces() {
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => void>()
const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
const loadOlder = vi.fn()
const inspectCall = vi.fn<(callId: string) => void>()
// In-memory scroll memory matching the apply.ts per-session map contract.
@ -1021,6 +1021,18 @@ describe('ChatView', () => {
})
})
it('names a workspace-folder Host refusal as a folder', async () => {
const openFile = vi.fn<(path: string) => Promise<void>>()
.mockRejectedValueOnce(new Error(''))
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
h.props.openFile = openFile
render(<h.ChatView {...h.props} />)
await act(async () => { h.toolOwners[0]!.openFile('.') })
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '无法打开文件夹' }).textContent).toContain('无法打开此文件夹')
})
})
it('ignores a Host refusal that settles after the dialog is dismissed', async () => {
let rejectRetry!: (error: unknown) => void
const openFile = vi.fn<(path: string) => Promise<void>>()