fix: cr
This commit is contained in:
parent
0148c9bef6
commit
2442e63360
10 changed files with 60 additions and 20 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/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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 的第二次调用。
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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={(
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>>()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue