07akioni 1 miesiąc temu
rodzic
commit
2442e63360

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/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

+ 4 - 4
.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md

@@ -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.

+ 4 - 4
.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md

@@ -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 的第二次调用。

+ 4 - 1
apps/web/tests/seeded-history.e2e.ts

@@ -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'])
   })
 })

+ 7 - 0
apps/web/tests/snapshots/seeded-history/file-open-failure.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"

+ 17 - 4
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -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={(
         <>

+ 4 - 3
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -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>

+ 4 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -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',

+ 1 - 1
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -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()
   })
 

+ 13 - 1
packages/client/ui-conversation/tests/chat-view.client.spec.tsx

@@ -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>>()