瀏覽代碼

fix(web): preview delivered file links in the sidebar

Yichen Jiang 1 周之前
父節點
當前提交
ca2a9017ef

+ 15 - 7
apps/web/tests/present.e2e.ts

@@ -123,6 +123,21 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
       await row.waitFor()
       expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2)
       expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt')
+      const beforePreview = (await opened()).length
+      const column = page.locator('[data-rightbar-col]')
+      for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) {
+        const mention = page.locator('code').getByRole('button', { name: `Open ${name} in sidebar`, exact: true })
+        await mention.click()
+        const preview = column.locator('[data-document-preview]')
+        await expect.poll(() => preview.getAttribute('data-textpreview-url'))
+          .toBe(`dsh-resource://file/session/${sessionId}/${encodeURIComponent(name)}`)
+        await preview.getByText(content, { exact: true }).waitFor()
+        await mention.click()
+        expect(await column.locator('[data-dockkit-tab]').filter({ hasText: name }).count()).toBe(1)
+      }
+      expect(await opened()).toHaveLength(beforePreview)
+      expect(downloads).toEqual([])
+      await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
       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')
@@ -143,13 +158,6 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
         expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes })
       }
     }
-    const count = (await opened()).length
-    const openedResponse = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
-    await page.locator('code').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click()
-    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({ 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)

+ 2 - 2
packages/client/ui-deliverables/README.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 packages/client/ui-deliverables/README.md
-README.md: 857b71c5e0f3e4ba0dac0e8453492684c76ca0e4
-README.zh.md: dadb1a9a581d21af02e0f1239b0f7801148269a1
+README.md: 4f1b853483464415f2e9df2cdf1c982f82eed16e
+README.zh.md: d2f38a74a60d322d0ea8410beaf4127436983623

+ 3 - 3
packages/client/ui-deliverables/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package renders the deliverables row a finished turn ends with — the files the mutation tools created or modified — and links matching inline-code references in the closing prose, so a mentioned file opens in the Host. The vocabulary comes from the mutation tools' own `locations`, never from the closing prose — a produced file is listed whether or not the model remembered to name it. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, row, and prose links together.
+This package renders the deliverables row a finished turn ends with — the files the mutation tools created or modified — and links matching inline-code references in the closing prose, so a mentioned file opens in the right Sidebar. The vocabulary comes from the mutation tools' own `locations`, never from the closing prose — a produced file is listed whether or not the model remembered to name it. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, row, and prose links together.
 
 ## Table of Contents
 
@@ -30,7 +30,7 @@ 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 files accessible through the Session filesystem, 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 one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. 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 files accessible through the Session filesystem, 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 one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. 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. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; 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.
 
@@ -95,7 +95,7 @@ The section is static at first-party order 9000 for the lifetime of the package
 These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog.
 
 - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it.
-- **Terminal-created files require explicit delivery** — call `present` to declare them for native opening.
+- **Terminal-created files require explicit delivery** — call `present` to make them available as delivery cards and clickable references.
 - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404.
 - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced.
 

+ 3 - 3
packages/client/ui-deliverables/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包渲染已完成轮次末尾的产出文件行——列出修改工具创建或修改的文件——并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在 Host 中打开。词表来自修改工具自身的 `locations`,而非收尾正文——无论模型是否记得点名,产出文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、文件行与正文链接。
+本包渲染已完成轮次末尾的产出文件行——列出修改工具创建或修改的文件——并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。词表来自修改工具自身的 `locations`,而非收尾正文——无论模型是否记得点名,产出文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、文件行与正文链接。
 
 ## 目录
 
@@ -30,7 +30,7 @@ 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)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。
 
 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
 
@@ -95,7 +95,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
-- **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开
+- **终端创建的文件需要显式交付**——调用 `present` 声明后才会显示交付卡片和可点击引用
 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。
 - **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。
 

+ 4 - 7
packages/client/ui-deliverables/src/client/index.ts

@@ -65,18 +65,15 @@ export function apply(ctx: ClientContext): void {
   // via ctx.get, so its absence — this plugin composed out — is the off state.
   const t = ctx.locale.bind(NS)
   const mentions: ChatFileMentions = {
-    forClosing(owner, sessionId) {
+    forClosing(owner) {
       // Same claim test the turn-tail chain entry runs: no produced files,
       // no vocabulary — the two surfaces agree by construction.
       const paths = selectProducedFiles(owner)
       const presented = presentedForClosing(owner)
       if (paths === null && presented.length === 0) return undefined
-      const deliveries = new Map(presented.map(file => [file.path, file]))
-      return producedFileMentions([...new Set([...paths ?? [], ...deliveries.keys()])], (path) => {
-        const file = deliveries.get(path)
-        if (file === undefined) owner.openFile(path)
-        else void opener.open(sessionId, file.seq, file.index)
-      }, path => t(deliveries.has(path) ? 'presented.open' : 'produced.open', { name: path }))
+      const deliveries = new Set(presented.map(file => file.path))
+      return producedFileMentions([...new Set([...paths ?? [], ...deliveries])], owner.openFile,
+        path => t(deliveries.has(path) ? 'presented.previewButton' : 'produced.open', { name: path }))
     },
   }
   ctx.provide('chatFileMentions', mentions)

+ 0 - 2
packages/client/ui-deliverables/src/client/locales.ts

@@ -38,7 +38,6 @@ export const zh = {
   'row.error': '交付失败',
   'row.stopped': '已中断',
   'row.inspect': '查看调用',
-  'presented.open': '在默认程序中打开 {name}',
   'produced.label': '本轮文件改动',
   'produced.moreOne': '+ 1 个文件',
   'produced.more': '+ {count} 个文件',
@@ -80,7 +79,6 @@ export const en: Record<DeliverablesKey, string> = {
   'row.error': 'Delivery failed',
   'row.stopped': 'Interrupted',
   'row.inspect': 'Inspect call',
-  'presented.open': 'Open {name} in default app',
   'produced.label': 'Files changed',
   'produced.moreOne': '+ 1 file',
   'produced.more': '+ {count} files',

+ 12 - 3
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -551,9 +551,18 @@ describe('plugin registration', () => {
     expect(opened).toEqual(['site/report.html'])
     const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
     vi.stubGlobal('fetch', fetcher)
-    const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', seq: 2, index: 0 }] }, 3)
-    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 preview = vi.fn<(path: string) => void>()
+    for (const produced of [[], [{ path: 'out/report.docx', seq: 1 }]]) {
+      const delivered = tailOwner({ produced, presented: [{ path: 'out/report.docx', seq: 2, index: 0 }] }, 3, preview)
+      const mentions = service?.forClosing(delivered, SessionId('child-session'))
+      for (const text of ['report.docx', 'out/report.docx']) {
+        const mention = mentions?.resolve(text)
+        expect(mention?.label).toBe('Open out/report.docx in sidebar')
+        mention?.open()
+      }
+    }
+    expect(preview.mock.calls).toEqual(Array.from({ length: 4 }, () => ['out/report.docx']))
+    expect(fetcher).not.toHaveBeenCalled()
     const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected
     fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' }))
     await face.reloadPresentedHost()

+ 2 - 2
snapshots/web/present/ui.expected.md

@@ -55,10 +55,10 @@
   - code: present
   - text: succeeded for
   - code:
-    - button "Open report.txt in default app": report.txt
+    - button "Open report.txt in sidebar": report.txt
   - text: and
   - code:
-    - button "Open 说明.txt in default app": 说明.txt
+    - button "Open 说明.txt in sidebar": 说明.txt
   - text: ", after which the program deliberately threw the string"
   - code: AFTER_PRESENT
   - text: — no retries, no extra files.