07akioni 1 lună în urmă
părinte
comite
f15ca23386

+ 2 - 2
.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.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/feature/2026-08-18-web-home-path-tilde.md
-2026-08-18-web-home-path-tilde.md: 4b9b24454bbeeb394480c0c30470b7383a257790
-2026-08-18-web-home-path-tilde.zh.md: d901caab361755de44f6384d1016faf125175822
+2026-08-18-web-home-path-tilde.md: b148833bab09eadce4c9c1a362dd99d04eba5977
+2026-08-18-web-home-path-tilde.zh.md: 9d15cd6dca1128927389d5731dff6bf831cffe76

+ 2 - 2
.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md

@@ -14,7 +14,7 @@ Workspace hover cards and Tool call summaries showed full POSIX home paths. Thos
 
 `abbreviateHomePath` in `dsh-client-runtime` is the display-only helper. It returns `~` or `~/…` when the path is the POSIX home or a descendant, and leaves the path unchanged when `home` is missing, empty, or `/`, when either value is a Windows drive or UNC path, or when the match is only a prefix (`/Users/u` does not claim `/Users/u2`). Tool summaries run workspace-relative shortening first, then this helper, so a path inside the session cwd stays short. `filePath`, Host open, and Workspace hover copy keep the authored filesystem path.
 
-`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. A missing `hostDescription` on an incomplete test fake falls back to an absent source, so abbreviation does not run.
+`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. The field is required on `ConnectionHandle`; test fakes supply a source whose snapshot may be undefined before connect.
 
 The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/fixture/Documents/project` lets assembled replay hover `~/Documents/project` without moving the existing `/tmp/fixture` account. TerminalBlock's own prompt-label collapse is unchanged.
 
@@ -30,7 +30,7 @@ The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/f
 
 ## Consequences
 
-POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Incomplete test connection fakes without `hostDescription` render unabbreviated paths instead of hanging or throwing.
+POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Before the first describe, or while reconnecting, the source snapshot is undefined and paths stay unabbreviated.
 
 ## Testing
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md

@@ -14,7 +14,7 @@ Workspace 悬停卡片和 Tool 调用摘要会显示完整的 POSIX 家目录路
 
 `dsh-client-runtime` 中的 `abbreviateHomePath` 是仅用于展示的辅助函数。当路径是 POSIX 家目录或其后代时返回 `~` 或 `~/…`;`home` 缺失、为空或为 `/`,任一侧是 Windows 盘符或 UNC 路径,或只是前缀命中(`/Users/u` 不能收走 `/Users/u2`)时,路径保持不变。Tool 摘要先做工作区相对缩短,再调用该辅助函数,因此会话 cwd 内的路径仍然更短。`filePath`、Host 打开以及 Workspace 悬停复制仍使用作者给出的文件系统路径。
 
-`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。测试假对象若缺少 `hostDescription`,会回退到空来源,因此不会进行缩写
+`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。该字段在 `ConnectionHandle` 上是必填的;测试假对象提供一个来源,其快照在连接完成前可以为 undefined
 
 fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位于 `/home/fixture/Documents/project`,组装回放可以悬停出 `~/Documents/project`,而不必移动现有的 `/tmp/fixture` 账户。TerminalBlock 自有的提示符标签折叠保持不变。
 
@@ -30,7 +30,7 @@ fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位
 
 ## Consequences
 
-POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。缺少 `hostDescription` 的不完整测试连接假对象会渲染未缩写路径,而不是挂起或抛错
+POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。首次 describe 之前或重连期间,来源快照为 undefined,路径保持未缩写
 
 ## Testing
 

+ 16 - 2
packages/client/ui-tool/tests/read-card.client.spec.tsx

@@ -261,7 +261,12 @@ describe('ReadRow keyed toolview', () => {
 })
 
 describe('DetailsPanel Output section (read)', () => {
-  function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
+  function mount(
+    snapshot: ConversationSnapshot,
+    selection: SelectionTarget | null,
+    cwd?: string,
+    description?: Parameters<typeof renderToolDetails>[1],
+  ) {
     localStorage.clear()
     const chat = createChatStore().create()
     if (selection !== null) chat.actions.select(selection)
@@ -282,7 +287,7 @@ describe('DetailsPanel Output section (read)', () => {
     return render(
       <DetailsPanel
         SessionProvider={SessionProviderStub}
-        renderSlot={renderToolDetails(t)}
+        renderSlot={renderToolDetails(t, description)}
         sessionId={SID}
         t={t}
         useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
@@ -342,6 +347,15 @@ describe('DetailsPanel Output section (read)', () => {
     expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result')
   })
 
+  it('abbreviates a leftover POSIX home path on the read card label', () => {
+    const view = mount(snapshot({
+      nodes: [settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) })],
+    }), target, '/tmp/ws', {
+      version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false,
+    })
+    expect(view.getByText('~/notes.md')).toBeTruthy()
+  })
+
   it('a running read keeps the 运行中… placeholder (no result view)', () => {
     const view = mount(snapshot({ runningCalls: [running()] }), target)
     expect(view.getByText('运行中…')).toBeTruthy()

+ 11 - 1
packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx

@@ -2,6 +2,7 @@
 /** ToolCallTree-owned root/subcall markers and selection projection. */
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
+import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
 import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -21,6 +22,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => (
 function props(
   block: ToolResultNode,
   selectedCallId?: string,
+  description?: HostDescription,
 ): ToolTreeProps {
   const snapshot = {} as ConversationSnapshot
   const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
@@ -44,7 +46,7 @@ function props(
     inspectCall: vi.fn(),
     forkAt: vi.fn(),
     fileMentions: vi.fn(),
-    useHostDescription: (selector => selector(undefined)) as ToolTreeProps['useHostDescription'],
+    useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'],
     t,
   } as unknown as ToolTreeProps
 }
@@ -79,4 +81,12 @@ describe('ToolCallTree', () => {
     expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
     expect(nests).toHaveLength(2)
   })
+
+  it('abbreviates a POSIX home path in the generic tool summary', () => {
+    const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' })
+    const view = render(<ToolCallTree {...props(block, 'w1', {
+      version: '0', cwd: '/tmp', attachedSessions: 0, home: '/h', canOpenPath: false,
+    })} />)
+    expect(view.getByText('~/docs/a.ts')).toBeTruthy()
+  })
 })

+ 7 - 2
packages/client/ui-tool/tests/tool-details-render.client.tsx

@@ -1,4 +1,5 @@
 /** Test adapter for the production conversation.details.tool registration. */
+import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
 import type {
   ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId,
 } from '@deepseek-ai/dsh-client-runtime/client'
@@ -51,9 +52,13 @@ export function toolChatSnapshot(
 /**
  * Bind ui-tool's details renderer to the conversation slot callback shape.
  * @param t - conversation locale seat used by Tool cards.
+ * @param description - optional Host description so the details card can abbreviate home paths.
  * @returns a direct-test renderSlot implementation.
  */
-export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotProps['renderSlot'] {
+export function renderToolDetails(
+  t: TranslateNS<'conversation'>,
+  description?: HostDescription,
+): DetailsSlotProps['renderSlot'] {
   return (_key, owner) => {
     // PropsRenderSlots keeps its key generic even for this one-key share;
     // recover the concrete owner selected by the adapter's fixed slot.
@@ -61,7 +66,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr
     return <ToolDetails
       block={details.block}
       cwd={details.cwd}
-      useHostDescription={selector => selector(undefined)}
+      useHostDescription={selector => selector(description)}
       t={t}
     />
   }

+ 0 - 8
packages/client/ui-tool/tests/tool-row.client.spec.tsx

@@ -5,7 +5,6 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
 import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { resolveWorkspacePath } from '@deepseek-ai/dsh-client-runtime/client'
 import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts'
 import { ToolRow } from '../src/client/tool/components/ToolRow.tsx'
 import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
@@ -120,13 +119,6 @@ describe('tool-call-model', () => {
     expect(toolRowModel('bash', running()).filePath).toBeUndefined()
   })
 
-  it('resolveWorkspacePath joins relative paths under cwd and passes absolute through', () => {
-    expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
-    expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
-    expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts')
-    expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts')
-  })
-
   it('displays workspace-rooted paths relative to the session cwd', () => {
     const cwd = '/Users/u/ws/'
     expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')

+ 5 - 12
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -22,8 +22,8 @@
  * and a hole has exactly one declaring entry — they carry the same owner
  * contract and the same occupant.
  */
-import type { HostDescription, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
-import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
+import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
+import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: pull the owner SlotMap merges into programs that resolve the
 // runtime shares below.
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -80,17 +80,14 @@ export type DirectoryPickingInjected = {
 }
 
 /** Component-side view of the picking share: the bound occupancy selector hook. */
-export type DirectoryPickingHooks = {
-  /** Selector hook over this surface's directory-flow occupancy. */
-  useDirectoryFlow: SnapshotSelectorHook<boolean>
-}
+export type DirectoryPickingHooks = PropsHooks<DirectoryPickingInjected['hooks']>
 
 /**
  * Browser-private injected share (arrives via the register inject factory).
  * Data reads use the global framework hooks; these are the Host actions the
  * browsing region drives.
  */
-export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
+export type WorkspaceBrowserInjected = {
   hooks: DirectoryPickingInjected['hooks'] & {
     /** Current generation's Host description, bound by the slot renderer. */
     hostDescription: HostDescriptionSource
@@ -148,11 +145,7 @@ export type WorkspaceBrowserProps =
   & PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
   & PropsStore<ReturnType<typeof createWorkspaceViewStore>>
   & Omit<WorkspaceBrowserInjected, 'hooks'>
-  & DirectoryPickingHooks
-  & {
-    /** Selector hook over the current generation's Host description. */
-    useHostDescription: SnapshotSelectorHook<HostDescription | undefined>
-  }
+  & PropsHooks<WorkspaceBrowserInjected['hooks']>
   & PropsLocale<'workspace'>
 
 /**

+ 21 - 0
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -96,6 +96,27 @@ function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrows
 }
 
 describe('WorkspaceBrowser', () => {
+  it('workspace hover card shows a POSIX home descendant as ~', () => {
+    vi.useFakeTimers()
+    try {
+      mount({
+        useWorkspaces: hook(workspaceState([{
+          ...workspace('project', []),
+          path: '/home/u/Documents/project',
+          title: 'Project',
+        }])),
+        useHostDescription: selector => selector({
+          version: '0', cwd: '/tmp', attachedSessions: 0, home: '/home/u', canOpenPath: false,
+        }),
+      })
+      fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
+      act(() => { vi.advanceTimersByTime(500) })
+      expect(screen.getByText('~/Documents/project')).toBeTruthy()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
   it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => {
     const pending = {
       ...workspaceState([]),