Просмотр исходного кода

fix(web): show artifact paths relative to the workspace

yudshj 1 день назад
Родитель
Сommit
5b671d7df3

+ 1 - 0
apps/web/tests/present.e2e.ts

@@ -122,6 +122,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
       const row = page.locator('[data-presented-files-row]')
       await row.waitFor()
       expect(await row.getByRole('button').count()).toBe(4)
+      expect(await row.getByTitle(join(cwd, 'report.txt'), { exact: true }).innerText()).toBe('report.txt')
       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')

+ 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: 3196202a053ae35bb3055666a680c9d6cfc2a2f8
-README.zh.md: bb394ae756820d7b536ea9326c0837cf59a85a01
+README.md: 3ff1f50999ee7efbf8c3e3c2e996d1ac208db5d4
+README.zh.md: 23b7ca1275c4a4315ef890627d745cf5157d0660

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

@@ -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 workspace files, 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 responsive cards with file names, types, descriptions, workspace paths, and a split Open button. Its primary action opens the source in the Host’s default application; the menu offers Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. The menu names the serving Host, independently of the browser’s operating system. 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 workspace files, 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 responsive cards with file names, types, descriptions, workspace-relative paths, and a split Open button. Hovering the path shows its full location. Its primary action opens the source in the Host’s default application; the menu offers Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. The menu names the serving Host, independently of the browser’s operating system. 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 `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Both actions share pending state and show progress, acknowledgement, or an action-specific retryable error. A missing desktop disables both actions; 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.
 

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

@@ -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)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明、工作区路径和分段“打开”按钮。主按钮在 Host 默认应用中打开源文件;菜单在 macOS 上提供“在 Finder 中显示”,在 Windows 和 WSL 上提供“在文件资源管理器中显示”,在 Linux 上通过默认文件管理器“打开所在文件夹”。菜单显示实际提供服务的 Host 名称,不依赖浏览器的操作系统。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明、工作区相对路径和分段“打开”按钮。悬停路径可查看完整位置。主按钮在 Host 默认应用中打开源文件;菜单在 macOS 上提供“在 Finder 中显示”,在 Windows 和 WSL 上提供“在文件资源管理器中显示”,在 Linux 上通过默认文件管理器“打开所在文件夹”。菜单显示实际提供服务的 Host 名称,不依赖浏览器的操作系统。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。
 
 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。Host 没有桌面时禁用两个操作;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
 

+ 2 - 1
packages/client/ui-deliverables/package.json

@@ -67,7 +67,8 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
     "@deepseek-ai/dsh-tool-present": "workspace:^",
-    "@deepseek-ai/dsh-api-session-controller": "workspace:^"
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 5 - 4
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -1,7 +1,7 @@
 /** Existing changed-file chips and explicitly declared files for a closing turn. */
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
+import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { PresentedOpenController } from './present-open.ts'
 import { ProducedFiles } from './ProducedFiles.tsx'
@@ -39,9 +39,10 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch
  * @param props - matched files, workspace opener, and localized copy.
  * @returns the closing turn's file rows.
  */
-export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick<TurnTailOwnerProps, 'openFile'> & {
+export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick<TurnTailOwnerProps, 'openFile'> & {
   matched: DeliverablesMatch
-} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & InjectFace<DeliverablesInjected>) {
+} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected>) {
+  const cwd = useSessions(state => state.byId[sessionId]?.cwd)
   const states = usePresentedOpen(value => value)
   const host = usePresentedHost(value => value)
   return <>
@@ -57,7 +58,7 @@ export function Deliverables({ matched, openFile, t, sessionId, openPresented, u
       </div>}
       {host !== null && host !== 'error' && !host.available && <span className={css.hostStatus}>{t('presented.unavailable')}</span>}
       <div className={css.presented} data-presented-files-row>
-        {matched.presented.map(file => <PresentedFileCard key={file.path} file={file}
+        {matched.presented.map(file => <PresentedFileCard key={file.path} file={file} cwd={cwd}
           phase={states[presentedFileUrl(sessionId, file.seq, file.index)]}
           host={host === 'error' ? null : host} t={t}
           onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}

+ 7 - 3
packages/client/ui-deliverables/src/client/PresentedFileCard.tsx

@@ -1,5 +1,6 @@
 /** File identity and explicit default-app or file-manager actions for one delivery. */
 import { useState } from 'react'
+import { relativizeToCwd, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
 import {
   Button, Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16,
   IconChevronDownOutline14, IconFolderOpenOutline16,
@@ -13,11 +14,12 @@ import css from './Deliverables.module.css'
 
 /**
  * Render independent file actions without nesting buttons inside a clickable card.
- * @param props - durable file metadata, Host capabilities, gesture status, and localized copy.
+ * @param props - durable file metadata, Session workspace root, Host capabilities, gesture status, and localized copy.
  * @returns the file card and its anchored action menu.
  */
-export function PresentedFileCard({ file, phase, host, onAction, t }: {
+export function PresentedFileCard({ file, cwd, phase, host, onAction, t }: {
   file: PresentedPath
+  cwd: string | undefined
   phase: PresentedOpenPhase | undefined
   host: PresentedHost | null
   onAction: (action: PresentedAction) => void
@@ -42,7 +44,9 @@ export function PresentedFileCard({ file, phase, host, onAction, t }: {
       </div>
     </div>
     <div className={css.footer}>
-      <span className={css.path}><IconFolderOpenOutline16 /><span>{file.path}</span></span>
+      <span className={css.path} title={resolveWorkspacePath(cwd, file.path)}>
+        <IconFolderOpenOutline16 /><span>{relativizeToCwd(file.path, cwd)}</span>
+      </span>
       <div className={css.split}>
         <Button segment="start" className={css.open} icon={<IconRightUpOutline16 />} disabled={disabled}
           aria-label={t('presented.open', { name: file.path })} onClick={() => { act('open') }}>

+ 13 - 0
packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx

@@ -8,6 +8,7 @@ import { en, zh } from '../src/client/locales.ts'
 
 afterEach(cleanup)
 const props = () => ({
+  cwd: undefined,
   file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 },
   host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const },
   phase: undefined,
@@ -93,3 +94,15 @@ it('supports keyboard selection and returns focus to the trigger on Escape', ()
   fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
   expect(document.activeElement).toBe(trigger)
 })
+
+
+it('shortens the workspace prefix while retaining the full location on hover and in action labels', () => {
+  const p = props()
+  const path = '/work/reports/result.pdf'
+  const view = render(<PresentedFileCard {...p} cwd="/work" file={{ ...p.file, path }} />)
+  expect(view.getByTitle(path).textContent).toBe('reports/result.pdf')
+  fireEvent.click(view.getByRole('button', { name: `Open ${path} in default app` }))
+  expect(p.onAction).toHaveBeenCalledWith('open')
+  view.rerender(<PresentedFileCard {...p} cwd="/work" />)
+  expect(view.getByTitle('/work/out/report.pdf').textContent).toBe('out/report.pdf')
+})

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

@@ -8,7 +8,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import { cleanup, fireEvent, render, within } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionLiveEventEntry, SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import {
   ConversationNodeAssembler, UiConversation,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -35,7 +35,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 
 function openProps(controller = new PresentedOpenController()) {
   controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
+  const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
   return {
+    useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
     reloadPresentedHost: vi.fn(() => controller.loadHost()),
     usePresentedHost: <T,>(select: (state: ReturnType<typeof controller.host.getSnapshot>) => T): T =>
       select(controller.host.getSnapshot()),

+ 3 - 0
packages/client/ui-deliverables/tsconfig.client.json

@@ -52,6 +52,9 @@
     },
     {
       "path": "../../fs/tool-present"
+    },
+    {
+      "path": "../../util/workspace-path"
     }
   ]
 }

+ 2 - 2
packages/client/ui-tool/src/client/tool/models/image-card-model.ts

@@ -1,7 +1,7 @@
 /** Pure image-card derivation from raw result content and metadata. @module */
 import type { AttachmentId, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
-import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
-import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
+import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path'
+import type { ToolCallBlock } from './tool-call-model.ts'
 import { parsedToolCall } from './raw-tool-call.ts'
 
 /**

+ 2 - 2
packages/client/ui-tool/src/client/tool/models/read-card-model.ts

@@ -1,7 +1,7 @@
 /** Pure read-card derivation from raw result content and metadata. @module */
 import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
-import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
-import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
+import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path'
+import type { ToolCallBlock } from './tool-call-model.ts'
 import { parsedToolCall, singleResultText } from './raw-tool-call.ts'
 
 /**

+ 1 - 13
packages/client/ui-tool/src/client/tool/models/tool-call-model.ts

@@ -10,7 +10,7 @@
 // that produces the values).
 import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
-import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
+import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path'
 
 export type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
 
@@ -159,18 +159,6 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
   others: [],
 }
 
-/**
- * Strip the workspace root from a workspace-rooted absolute path (display only).
- * @param text - the path to shorten.
- * @param cwd - session workspace root; absent or empty leaves the path unchanged.
- * @returns the path relative to the workspace root, or unchanged when it is not rooted there.
- */
-export function relativizeToCwd(text: string, cwd: string | undefined): string {
-  if (cwd === undefined || cwd === '') return text
-  const root = cwd.replace(/[/\\]+$/, '')
-  if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
-  return text
-}
 
 function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
   const parsed = parseArgs(argsRaw)

+ 2 - 2
packages/util/workspace-path/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/util/workspace-path/README.md
-README.md: ed7cb3bc5089ac76058bd4c7447b1656b0ab2b24
-README.zh.md: 471f04af640d1121d8a0dc4f39da0d8c1f5a62d6
+README.md: ab7787f3649c8f75a0f0e0745bc78ea32edb5925
+README.zh.md: 270c79f7bb57def13a7656e8f76f900c1e094f4f

+ 1 - 1
packages/util/workspace-path/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Browser-safe path helpers shared by Workspace-facing client and controller packages. The package joins Workspace-relative paths, abbreviates POSIX home directories for display, derives Workspace titles from POSIX or Windows paths, splits a path into its directories and final segment for display, and owns the `dsh-resource://file/…` address grammar that names a workspace file across the Sidebar and the resource model. It has no Cordis service or runtime state.
+Browser-safe path helpers shared by Workspace-facing client and controller packages. The package joins Workspace-relative paths, abbreviates POSIX home directories for display, derives Workspace titles from POSIX or Windows paths, splits a path into its directories and final segment for display, and owns the `dsh-resource://file/…` address grammar that names a workspace file across the Sidebar and the resource model. `relativizeToCwd` removes the workspace prefix for display while preserving paths outside that directory. It has no Cordis service or runtime state.
 
 ## Table of Contents
 

+ 1 - 1
packages/util/workspace-path/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-library"
 
 ## 概述
 
-供 Workspace 相关客户端和控制器包共享、可在浏览器使用的路径辅助函数。该包负责拼接 Workspace 相对路径、缩写用于展示的 POSIX 主目录、从 POSIX 或 Windows 路径提取 Workspace 标题、把路径拆成目录部分与末段供展示,并拥有在 Sidebar 与资源模型之间命名工作区文件的 `dsh-resource://file/…` 地址语法它不提供 Cordis service,也不持有运行时状态。
+供 Workspace 相关客户端和控制器包共享、可在浏览器使用的路径辅助函数。该包负责拼接 Workspace 相对路径、缩写用于展示的 POSIX 主目录、从 POSIX 或 Windows 路径提取 Workspace 标题、把路径拆成目录部分与末段供展示,并拥有在 Sidebar 与资源模型之间命名工作区文件的 `dsh-resource://file/…` 地址语法。`relativizeToCwd` 在显示时省略工作区前缀,并保留该目录以外的路径。它不提供 Cordis service,也不持有运行时状态。
 
 ## 目录
 

+ 13 - 0
packages/util/workspace-path/src/index.ts

@@ -97,3 +97,16 @@ export function fileAddressFor(sessionId: string, cwd: string | undefined, path:
   if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1))
   return sessionFileAddress(sessionId, normalized)
 }
+
+/**
+ * Strip the workspace root from a workspace-rooted absolute path (display only).
+ * @param text - the path to shorten.
+ * @param cwd - session workspace root; absent or empty leaves the path unchanged.
+ * @returns the path relative to the workspace root, or unchanged when it is not rooted there.
+ */
+export function relativizeToCwd(text: string, cwd: string | undefined): string {
+  if (cwd === undefined || cwd === '') return text
+  const root = cwd.replace(/[/\\]+$/, '')
+  if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
+  return text
+}

+ 17 - 2
packages/util/workspace-path/tests/index.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import {
-  abbreviateHomePath, fileAddressFor, isAbsoluteWorkspacePath, parseFileAddress, pathPartsOf, resolveWorkspacePath,
-  workspaceTitleOf,
+  abbreviateHomePath, fileAddressFor, isAbsoluteWorkspacePath, parseFileAddress, pathPartsOf, relativizeToCwd,
+  resolveWorkspacePath, workspaceTitleOf,
 } from '@deepseek-ai/dsh-util-workspace-path'
 
 describe('Workspace path helpers', () => {
@@ -78,4 +78,19 @@ describe('Workspace path helpers', () => {
     expect(pathPartsOf('notes.md')).toEqual({ directory: '', name: 'notes.md' })
     expect(pathPartsOf('/')).toEqual({ directory: '', name: '/' })
   })
+
+  it.each([
+    ['/work/report.txt', '/work', 'report.txt'],
+    ['/work/reports/result.pdf', '/work/', 'reports/result.pdf'],
+    ['/work-other/report.txt', '/work', '/work-other/report.txt'],
+    ['/other/report.txt', '/work', '/other/report.txt'],
+    ['report.txt', '/work', 'report.txt'],
+    ['/work/report.txt', undefined, '/work/report.txt'],
+    ['/work/report.txt', '', '/work/report.txt'],
+    ['/report.txt', '/', 'report.txt'],
+    [String.raw`C:\work\reports\result.pdf`, String.raw`C:\work`, String.raw`reports\result.pdf`],
+    [String.raw`\\server\share\report.txt`, String.raw`\\server\share`, 'report.txt'],
+  ])('displays %s relative to %s only within that workspace', (path, cwd, label) => {
+    expect(relativizeToCwd(path, cwd)).toBe(label)
+  })
 })

+ 3 - 0
pnpm-lock.yaml

@@ -2738,6 +2738,9 @@ importers:
       '@deepseek-ai/dsh-tool-present':
         specifier: workspace:^
         version: link:../../fs/tool-present
+      '@deepseek-ai/dsh-util-workspace-path':
+        specifier: workspace:^
+        version: link:../../util/workspace-path
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31