Преглед на файлове

feat(ui-tool): integrate connection handling for POSIX home path abbreviation

07akioni преди 1 месец
родител
ревизия
20a5f5a3ee
променени са 64 файла, в които са добавени 473 реда и са изтрити 80 реда
  1. 6 0
      .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml
  2. 37 0
      .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md
  3. 37 0
      .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md
  4. 3 1
      apps/web/tests/assembled-boot.ts
  5. 45 0
      apps/web/tests/home-path-tilde.snapshot.ts
  6. 2 0
      apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt
  7. 9 2
      packages/client/connection/src/client/fixture.ts
  8. 1 1
      packages/client/connection/src/client/index.ts
  9. 5 5
      packages/client/connection/tests/connection.client.spec.ts
  10. 2 1
      packages/client/connection/tests/fake-api.client.ts
  11. 13 5
      packages/client/connection/tests/fixture.client.spec.ts
  12. 2 2
      packages/client/runtime/README.i18n.yaml
  13. 2 0
      packages/client/runtime/README.md
  14. 2 0
      packages/client/runtime/README.zh.md
  15. 1 1
      packages/client/runtime/src/client/index.ts
  16. 24 1
      packages/client/runtime/src/client/workspaces/path.ts
  17. 2 2
      packages/client/runtime/tests/client-apply.client.spec.ts
  18. 2 1
      packages/client/runtime/tests/fake-api.client.ts
  19. 41 0
      packages/client/runtime/tests/path.client.spec.ts
  20. 1 1
      packages/client/runtime/tests/wire-events.client.spec.ts
  21. 1 1
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  22. 2 2
      packages/client/ui-tool/README.i18n.yaml
  23. 1 1
      packages/client/ui-tool/README.md
  24. 1 1
      packages/client/ui-tool/README.zh.md
  25. 2 0
      packages/client/ui-tool/package.json
  26. 13 2
      packages/client/ui-tool/src/client/apply.ts
  27. 16 2
      packages/client/ui-tool/src/client/contract/slots.ts
  28. 3 1
      packages/client/ui-tool/src/client/index.ts
  29. 11 4
      packages/client/ui-tool/src/client/tool/ToolCallTree.tsx
  30. 6 10
      packages/client/ui-tool/src/client/tool/ToolDetails.tsx
  31. 6 4
      packages/client/ui-tool/src/client/tool/models/read-card-model.ts
  32. 6 2
      packages/client/ui-tool/src/client/tool/models/tool-call-model.ts
  33. 3 3
      packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx
  34. 2 2
      packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx
  35. 3 3
      packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx
  36. 9 0
      packages/client/ui-tool/tests/read-card.client.spec.tsx
  37. 1 0
      packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
  38. 7 1
      packages/client/ui-tool/tests/tool-details-render.client.tsx
  39. 26 0
      packages/client/ui-tool/tests/tool-row.client.spec.tsx
  40. 3 0
      packages/client/ui-tool/tsconfig.json
  41. 2 2
      packages/client/ui-workspace/README.i18n.yaml
  42. 0 0
      packages/client/ui-workspace/README.md
  43. 0 0
      packages/client/ui-workspace/README.zh.md
  44. 3 0
      packages/client/ui-workspace/package.json
  45. 7 1
      packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
  46. 9 0
      packages/client/ui-workspace/src/client/contract/slots.ts
  47. 10 2
      packages/client/ui-workspace/src/client/index.ts
  48. 12 3
      packages/client/ui-workspace/src/client/rows/Rows.tsx
  49. 5 1
      packages/client/ui-workspace/tests/apply.client.spec.ts
  50. 3 0
      packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx
  51. 38 0
      packages/client/ui-workspace/tests/rows.client.spec.tsx
  52. 1 0
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
  53. 3 0
      packages/client/ui-workspace/tsconfig.json
  54. 2 2
      packages/host/apiproxy/README.i18n.yaml
  55. 1 1
      packages/host/apiproxy/README.md
  56. 1 1
      packages/host/apiproxy/README.zh.md
  57. 2 0
      packages/host/apiproxy/src/api-proxy.ts
  58. 1 0
      packages/host/apiproxy/src/api/host.schema.ts
  59. 2 0
      packages/host/apiproxy/src/api/host.ts
  60. 2 1
      packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
  61. 1 1
      packages/host/apiproxy/tests/client-handler.spec.ts
  62. 1 1
      packages/host/apiproxy/tests/fetch-carrier.spec.ts
  63. 5 2
      packages/host/apiproxy/tests/rpc-schemas.spec.ts
  64. 3 0
      pnpm-lock.yaml

+ 6 - 0
.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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

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

@@ -0,0 +1,37 @@
+# Agent Note: Web UI abbreviates POSIX home paths as `~`
+
+Status: implemented
+
+English | [中文](2026-08-18-web-home-path-tilde.zh.md)
+
+## Problem
+
+Workspace hover cards and Tool call summaries showed full POSIX home paths. Those strings are long, repeat the same prefix on every row, and make the sidebar and transcript harder to scan. Windows paths must stay verbatim because `~` is not a Windows filesystem convention.
+
+## Decision
+
+`host.describe` reports the host account `home` as a required field. Client and Host ship together, so the field is required rather than optional. ApiProxy fills it from `homedir()` at describe time.
+
+`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.
+
+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.
+
+## Alternatives considered
+
+**Guess `/Users` or `/home` without the real home.** Rejected because a shared prefix is not an account home, and `/Users/shared` or `/home/src` would abbreviate incorrectly.
+
+**Abbreviate Windows `%USERPROFILE%` as `~` as well.** Rejected because the acceptance rule keeps Windows paths verbatim, and `~` is not how Explorer or `cmd` spell those paths.
+
+**Put the helper in `dsh-home-paths`.** Rejected because that package expands configuration tildes on Node; this helper is a browser display rewrite and must not pull Node `os` into client bundles.
+
+**Thread `home` from ChatView owner props.** Rejected because it enlarges the conversation inject face and every ChatView test harness for a display fact only Tool and Workspace cards consume.
+
+## 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.
+
+## Testing
+
+Package tests cover `abbreviateHomePath`, `toolRowModel` / `readCardModel` home abbreviation, Workspace hover display versus copy, and `host.describe` schema plus live `homedir()`. Assembled replay `apps/web/tests/home-path-tilde.snapshot.ts` hovers the fixture home-descendant Workspace. Product-GUI PRs still record a real-browser GIF of the hover card.

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

@@ -0,0 +1,37 @@
+# Agent Note: Web UI abbreviates POSIX home paths as `~`
+
+Status: implemented
+
+[English](2026-08-18-web-home-path-tilde.md) | 中文
+
+## Problem
+
+Workspace 悬停卡片和 Tool 调用摘要会显示完整的 POSIX 家目录路径。这些字符串很长,每行重复同一前缀,侧边栏和对话记录更难扫读。Windows 路径必须保持原样,因为 `~` 不是 Windows 文件系统约定。
+
+## Decision
+
+`host.describe` 把宿主账户的 `home` 作为必填字段上报。Client 与 Host 一同发布,因此该字段是必填而不是可选。ApiProxy 在 describe 时用 `homedir()` 填入。
+
+`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`,会回退到空来源,因此不会进行缩写。
+
+fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位于 `/home/fixture/Documents/project`,组装回放可以悬停出 `~/Documents/project`,而不必移动现有的 `/tmp/fixture` 账户。TerminalBlock 自有的提示符标签折叠保持不变。
+
+## Alternatives considered
+
+**在没有真实 home 的情况下猜测 `/Users` 或 `/home`。** 否决,因为共享前缀不是账户家目录,`/Users/shared` 或 `/home/src` 会被错误缩写。
+
+**同样把 Windows `%USERPROFILE%` 缩写成 `~`。** 否决,因为验收规则要求 Windows 路径保持原样,而且 Explorer 与 `cmd` 并不这样拼写这些路径。
+
+**把辅助函数放进 `dsh-home-paths`。** 否决,因为该包在 Node 上展开配置里的波浪号;本辅助函数是浏览器展示改写,不能把 Node `os` 拉进 client 包。
+
+**从 ChatView owner props 向下传递 `home`。** 否决,因为它会扩大 conversation 注入面和每一份 ChatView 测试夹具,而只有 Tool 与 Workspace 卡片消费这个展示事实。
+
+## Consequences
+
+POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。缺少 `hostDescription` 的不完整测试连接假对象会渲染未缩写路径,而不是挂起或抛错。
+
+## Testing
+
+包测试覆盖 `abbreviateHomePath`、`toolRowModel`/`readCardModel` 的家目录缩写、Workspace 悬停展示与复制,以及 `host.describe` schema 与实时 `homedir()`。组装回放 `apps/web/tests/home-path-tilde.snapshot.ts` 悬停 fixture 中位于家目录下的 Workspace。面向产品 GUI 的 PR 仍需录制悬停卡片的真实浏览器 GIF。

+ 3 - 1
apps/web/tests/assembled-boot.ts

@@ -30,7 +30,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
   { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
   { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
   { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
+  { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
   { id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
   {
     id: '@deepseek-ai/dsh-client-ui-workspace',
@@ -38,6 +38,8 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
     url: '/plugins/ui-workspace.js',
     rev: 'fx',
     inject: [
+      '@deepseek-ai/dsh-client-connection',
+      '@deepseek-ai/dsh-client-locale',
       '@deepseek-ai/dsh-client-runtime',
       '@deepseek-ai/dsh-client-ui-conversation',
       '@deepseek-ai/dsh-client-ui-sidebar',

+ 45 - 0
apps/web/tests/home-path-tilde.snapshot.ts

@@ -0,0 +1,45 @@
+// @vitest-environment jsdom
+// Assembled POSIX home-path display: the fixture Host home is `/home/fixture`
+// and a second Workspace lives under it. The sidebar hover card must show
+// `~/Documents/project` while copy still writes the full path.
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts'
+
+const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt')
+
+installAssembledBootEnv()
+
+describe('assembled POSIX home-path display', () => {
+  it('shows the home-descendant Workspace path as ~ and copies the full path', async () => {
+    mountAssembledApp()
+
+    const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+    const group = (await within(tree).findAllByText('project'))
+      .map(el => el.closest<HTMLElement>('[role="treeitem"]'))
+      .find(el => el?.getAttribute('aria-expanded') !== null)
+    if (group == null) throw new Error('home-descendant Workspace group missing')
+
+    fireEvent.pointerEnter(group.parentElement as HTMLElement)
+    const hoverPath = await waitFor(() => {
+      const found = screen.getByText('~/Documents/project')
+      expect(found).toBeTruthy()
+      return found
+    }, { timeout: 2_000 })
+    expect(screen.queryByText('/home/fixture/Documents/project')).toBeNull()
+    const copy = screen.getByRole('button', { name: 'Copy: /home/fixture/Documents/project' })
+
+    const shape = [
+      `hover=${hoverPath.textContent}`,
+      `copy=${copy.getAttribute('aria-label')}`,
+    ].join('\n') + '\n'
+    if (REFRESHING_GOLDEN) {
+      mkdirSync(dirname(EXPECTED), { recursive: true })
+      writeFileSync(EXPECTED, shape)
+    }
+    await expect(shape).toMatchFileSnapshot(EXPECTED)
+    act(() => { fireEvent.pointerLeave(group.parentElement as HTMLElement) })
+  })
+})

+ 2 - 0
apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt

@@ -0,0 +1,2 @@
+hover=~/Documents/project
+copy=Copy: /home/fixture/Documents/project

+ 9 - 2
packages/client/connection/src/client/fixture.ts

@@ -1551,6 +1551,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
   // live under one workspace, whose account carries them in attach order.
   const wid = (raw: string): WorkspaceId => raw as WorkspaceId
   const fixtureEpoch = new Date(Date.now() - 300_000).toISOString()
+  const FIXTURE_HOME = '/home/fixture'
   const workspaces: WorkspaceView[] = options.empty ? [] : [{
     workspaceId: wid('fx-ws-fixture'),
     path: '/tmp/fixture',
@@ -1558,6 +1559,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')],
     createdAt: fixtureEpoch,
     updatedAt: fixtureEpoch,
+  }, {
+    workspaceId: wid('fx-ws-home'),
+    path: `${FIXTURE_HOME}/Documents/project`,
+    title: 'project',
+    sessionIds: [],
+    createdAt: fixtureEpoch,
+    updatedAt: fixtureEpoch,
   }]
   let nextWorkspace = 1
   // Registry-global archive set mirroring the host: archived sessions keep
@@ -1568,7 +1576,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
   // deterministic content mirroring the design mock so assembled Web tests
   // and snapshots can walk it. Leaves are materialized lazily: a child listed
   // by its parent lists as empty until something is created inside it.
-  const FIXTURE_HOME = '/home/fixture'
   const directoryTree = new Map<string, string[]>([
     ['/', ['home']],
     ['/home', ['fixture']],
@@ -2523,7 +2530,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     },
     host: {
       describe: request => ok(request, {
-        version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true,
+        version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true,
       }),
       // Deterministic native pick: the keyless lanes drive the full
       // pick-then-adopt path without an OS chooser (design-mock content,

+ 1 - 1
packages/client/connection/src/client/index.ts

@@ -62,7 +62,7 @@ export interface ConnectionHandle {
   readonly api: IApiClient
   /** Whether the current page authority is loopback; non-browser contexts default to true. */
   readonly isLoopback: boolean
-  /** Generation-scoped Host facts, including native path-open capability. */
+  /** Generation-scoped Host facts, including the account home and native path-open capability. */
   readonly hostDescription: HostDescriptionSource
   /** Generic logical RPC channels over the same Connection transport. */
   readonly rpc: ClientConnectionRpc

+ 5 - 5
packages/client/connection/tests/connection.client.spec.ts

@@ -80,7 +80,7 @@ describe('connection lifecycle', () => {
     try {
       await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
       expect(connected).toBe(0) // never announced during the failed generation
-      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
       await vi.waitFor(() => { expect(connected).toBe(1) })
     } finally {
       controller.stop()
@@ -102,7 +102,7 @@ describe('connection lifecycle', () => {
           },
         })
       }
-      return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+      return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
     }
     let connected = 0
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -188,7 +188,7 @@ describe('connection lifecycle', () => {
       describeCalls++
       return describeCalls === 1
         ? firstDescribe.promise
-        : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+        : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
     }
     const states: ConnectionState[] = []
     let connected = 0
@@ -201,7 +201,7 @@ describe('connection lifecycle', () => {
     try {
       await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) })
       api.endStreams()
-      firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+      firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
 
       await vi.waitFor(() => { expect(describeCalls).toBe(2) })
       await vi.waitFor(() => { expect(connected).toBe(1) })
@@ -283,7 +283,7 @@ describe('connection lifecycle', () => {
     controller.start()
     try {
       await vi.waitFor(() => { expect(describeCalls).toBe(3) })
-      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
       await vi.waitFor(() => { expect(connected).toBe(1) })
       expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
     } finally {

+ 2 - 1
packages/client/connection/tests/fake-api.client.ts

@@ -74,10 +74,11 @@ export class FakeApiClient implements IApiClient {
     version: string
     cwd: string
     attachedSessions: number
+    home: string
     canOpenPath: boolean
   }>> =
     () => Promise.resolve(ok({
-      version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
+      version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
     }))
   onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
     () => Promise.resolve(ok({ path: null }))

+ 13 - 5
packages/client/connection/tests/fixture.client.spec.ts

@@ -522,7 +522,9 @@ describe('createFixtureApi', () => {
   it('describe answers the fixture identity', async () => {
     const api = createFixtureApi()
     const response = await api.host.describe(req({}))
-    expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
+    expect(response.result).toMatchObject({
+      ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' },
+    })
     const empty = await createFixtureApi({ empty: true }).host.describe(req({}))
     expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
   })
@@ -547,10 +549,16 @@ describe('createFixtureApi', () => {
     const api = createFixtureApi()
     const listed = await api.workspace.list(req({}))
     if (!listed.result.ok) throw new Error('list failed')
-    expect(listed.result.value.items).toEqual([expect.objectContaining({
-      workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
-      sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
-    })])
+    expect(listed.result.value.items).toEqual([
+      expect.objectContaining({
+        workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
+        sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
+      }),
+      expect.objectContaining({
+        workspaceId: 'fx-ws-home', path: '/home/fixture/Documents/project', title: 'project',
+        sessionIds: [],
+      }),
+    ])
     // path collision → the existing entity comes back, created:false, no frame.
     const reused = await api.workspace.create(req({ path: '/tmp/fixture' }))
     if (!reused.result.ok) throw new Error('reuse failed')

+ 2 - 2
packages/client/runtime/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/runtime/README.md
-README.md: 1fb91bc8ca1bf9beae0ea12632acd9572db58670
-README.zh.md: 294a77d7081f5b475aca8b50f6c6d8322370375d
+README.md: 51156bf60acebe0367ce7dedeb8d478b7f8fc148
+README.zh.md: 2c7838769367df1f4129064e3de2e64db5bb2eb4

+ 2 - 0
packages/client/runtime/README.md

@@ -26,6 +26,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
 
 SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
 
+`abbreviateHomePath` is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged.
+
 `indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
 
 `SessionListState.jobsBySession` mirrors the Host's `session/jobs` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order.

+ 2 - 0
packages/client/runtime/README.zh.md

@@ -26,6 +26,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
 
 SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。
 
+`abbreviateHomePath` 是 Web Workspace 悬停卡片与 Tool 摘要使用的仅展示 POSIX 家目录缩写;Windows 盘符或 UNC 路径保持原样,缺失、空或文件系统根的 home 不改写路径。
+
 `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
 
 `SessionListState.jobsBySession` 按 last-wins 镜像宿主的 `session/jobs` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。

+ 1 - 1
packages/client/runtime/src/client/index.ts

@@ -46,7 +46,7 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
 export { createScope } from './agents/scope.ts'
 export type { AgentScopeHandle } from './agents/scope.ts'
 export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts'
-export { resolveWorkspacePath } from './workspaces/path.ts'
+export { abbreviateHomePath, resolveWorkspacePath } from './workspaces/path.ts'
 // Contract only: the scope implementation and its Host transport belong to
 // dsh-client-ui-settings (see that package's settings-scope.ts).
 export type {

+ 24 - 1
packages/client/runtime/src/client/workspaces/path.ts

@@ -5,9 +5,32 @@
  * @returns an absolute path when a workspace root is available, otherwise the original path.
  */
 export function resolveWorkspacePath(cwd: string | undefined, path: string): string {
-  if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
+  if (path.startsWith('/') || isWindowsStylePath(path)) return path
   if (cwd === undefined || cwd === '') return path
   const base = cwd.replace(/[/\\]+$/, '')
   const rel = path.replace(/^[/\\]+/, '')
   return `${base}/${rel}`
 }
+
+/** Drive-letter or UNC path; Web display must not rewrite these as `~`. */
+function isWindowsStylePath(value: string): boolean {
+  return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\')
+}
+
+/**
+ * Display-only POSIX home abbreviation. Windows drive and UNC paths stay
+ * verbatim, including when `home` itself is a Windows path. A missing, empty,
+ * or filesystem-root `home` leaves `path` unchanged so `/` cannot become `~`.
+ * @param path - absolute or already-short display path.
+ * @param home - host account home from `host.describe`; absent skips abbreviation.
+ * @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`.
+ */
+export function abbreviateHomePath(path: string, home?: string): string {
+  if (home === undefined || home === '') return path
+  if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path
+  const root = home.replace(/\/+$/, '')
+  if (root === '' || root === '/') return path
+  if (path.replace(/\/+$/, '') === root) return '~'
+  if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}`
+  return path
+}

+ 2 - 2
packages/client/runtime/tests/client-apply.client.spec.ts

@@ -91,7 +91,7 @@ describe('runtime client apply', () => {
     expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
     // Mux sink and onConnected route without throwing (manager semantics own the behavior).
     bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
-    bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
+    bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })
   })
 
   it('selects the recent Workspace once when the first baselines have no current session', async () => {
@@ -104,7 +104,7 @@ describe('runtime client apply', () => {
     }))
     bench.api.onList = () => Promise.resolve(ok({ items: [] }))
 
-    bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
+    bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })
     await flushMicrotasks()
 
     const sessions = bench.ctx.get('sessions') as SessionRuntime

+ 2 - 1
packages/client/runtime/tests/fake-api.client.ts

@@ -108,10 +108,11 @@ export class FakeApiClient implements IApiClient {
     version: string
     cwd: string
     attachedSessions: number
+    home: string
     canOpenPath: boolean
   }>> =
     () => Promise.resolve(ok({
-      version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
+      version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
     }))
   onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
     () => Promise.resolve(ok({ path: null }))

+ 41 - 0
packages/client/runtime/tests/path.client.spec.ts

@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/workspaces/path.ts'
+
+describe('abbreviateHomePath', () => {
+  it('collapses a POSIX home and its descendants', () => {
+    expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~')
+    expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~')
+    expect(abbreviateHomePath('/Users/u/Documents/project', '/Users/u')).toBe('~/Documents/project')
+    expect(abbreviateHomePath('/Users/u/Documents/project/', '/Users/u/')).toBe('~/Documents/project/')
+  })
+
+  it('keeps prefix-adjacent names and non-home paths', () => {
+    expect(abbreviateHomePath('/Users/u2/a.ts', '/Users/u')).toBe('/Users/u2/a.ts')
+    expect(abbreviateHomePath('/etc/hosts', '/Users/u')).toBe('/etc/hosts')
+    expect(abbreviateHomePath('src/a.ts', '/Users/u')).toBe('src/a.ts')
+    expect(abbreviateHomePath('~/already', '/Users/u')).toBe('~/already')
+  })
+
+  it('does not abbreviate when home is missing, empty, or the filesystem root', () => {
+    expect(abbreviateHomePath('/Users/u/a.ts')).toBe('/Users/u/a.ts')
+    expect(abbreviateHomePath('/Users/u/a.ts', '')).toBe('/Users/u/a.ts')
+    expect(abbreviateHomePath('/etc/hosts', '/')).toBe('/etc/hosts')
+    expect(abbreviateHomePath('/etc/hosts', '///')).toBe('/etc/hosts')
+  })
+
+  it('leaves Windows drive and UNC paths verbatim', () => {
+    expect(abbreviateHomePath('C:\\Users\\u\\project', 'C:\\Users\\u')).toBe('C:\\Users\\u\\project')
+    expect(abbreviateHomePath('C:/Users/u/project', '/Users/u')).toBe('C:/Users/u/project')
+    expect(abbreviateHomePath('/Users/u/project', 'C:\\Users\\u')).toBe('/Users/u/project')
+    expect(abbreviateHomePath('\\\\server\\share\\u', '\\\\server\\share\\u')).toBe('\\\\server\\share\\u')
+  })
+})
+
+describe('resolveWorkspacePath', () => {
+  it('joins a relative path under cwd and passes absolute paths 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')
+  })
+})

+ 1 - 1
packages/client/runtime/tests/wire-events.client.spec.ts

@@ -127,7 +127,7 @@ describe('wire event bridge', () => {
     const bench = await mount()
     let resets = 0
     bench.ctx.on('connection/reset', () => { resets++ })
-    const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }
+    const description = { version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }
     bench.sinks?.onConnected?.(description)
     bench.sinks?.onConnected?.(description) // second generation after a reconnect
     expect(resets).toBe(2)

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

@@ -286,7 +286,7 @@ describe('ProducedFiles row', () => {
   ): Pick<ProducedFilesProps, 'isLoopback' | 'useHostDescription'> => {
     const description = canOpenPath === undefined
       ? undefined
-      : { version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath }
+      : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath }
     return {
       isLoopback,
       useHostDescription: selector => selector(description),

+ 2 - 2
packages/client/ui-tool/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-tool/README.md
-README.md: 6e2bef2f5ad4b136510c3acbb8f8b8e83c4c9212
-README.zh.md: 169417747541db9e02cb552b175ca7c100420aeb
+README.md: b87236309c9bafe3e35d3d5977d56bd62a24de31
+README.zh.md: 3bae0b3f4cb3ad695371ec7a66fb531a935a4d2f

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

@@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () =>
   }, BusinessToolRow))
 ```
 
-The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd`, and plain `openFile`/`inspect` callbacks. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge.
+The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. Path summaries relativize to the session cwd first, then replace a leftover POSIX host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge.
 
 This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`.
 

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

@@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () =>
   }, BusinessToolRow))
 ```
 
-owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd`,以及普通的 `openFile`、`inspect` 回调。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。
+owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX 宿主家目录写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。
 
 本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
 

+ 2 - 0
packages/client/ui-tool/package.json

@@ -32,6 +32,7 @@
   "dsh": {
     "client": {
       "inject": [
+        "@deepseek-ai/dsh-client-connection",
         "@deepseek-ai/dsh-client-runtime",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-conversation"
@@ -50,6 +51,7 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",

+ 13 - 2
packages/client/ui-tool/src/client/apply.ts

@@ -1,4 +1,5 @@
 /** Register the Tool call tree, details renderer, and built-in atomic views. */
+import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { ToolCallTree } from './tool/ToolCallTree.tsx'
@@ -12,14 +13,22 @@ import { searchToolview } from './tool/toolviews/search-row.tsx'
 import { todoToolview } from './tool/toolviews/todo-row.tsx'
 import { webToolview } from './tool/toolviews/web-row.tsx'
 
-/** Required service: the slot registry that owns both Tool render seats. */
-export const inject = ['slots']
+/** Required services: the slot registry and the Host description used for POSIX `~`. */
+export const inject = ['slots', 'connection']
+
+const absentHostDescription: HostDescriptionSource = {
+  getSnapshot: () => undefined,
+  subscribe: () => () => {},
+}
 
 /**
  * Mount the whole-Tool renderers and built-in atomic Tool registrations.
  * @param ctx - Client root context.
  */
 export function apply(ctx: ClientContext): void {
+  const connection = ctx.get('connection') as ConnectionHandle
+  const hostDescription = connection.hostDescription ?? absentHostDescription
+  const toolInject = () => ({ hooks: { hostDescription } })
   ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
     name: 'conversation.chat.node',
     key: 'tool-call',
@@ -27,11 +36,13 @@ export function apply(ctx: ClientContext): void {
     children: {
       'tool.call.toolview': { kind: 'keyed', scope: 'session' },
     },
+    inject: toolInject,
   }, ToolCallTree))
 
   ctx.slots.inject('conversation.details.tool', () => ctx.slots.register({
     name: 'conversation.details.tool',
     locale: NS,
+    inject: toolInject,
   }, ToolDetails))
 
   ctx.plugin(bashToolviewSample)

+ 16 - 2
packages/client/ui-tool/src/client/contract/slots.ts

@@ -1,5 +1,6 @@
 /** Tool UI slot declarations and their composed component props. */
-import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
+import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -34,6 +35,8 @@ export interface ToolCallOwnerProps {
   block: ToolCallBlock
   /** Session workspace root for relative summaries. */
   cwd?: string | undefined
+  /** Host account home; POSIX home-rooted summaries display as `~`. */
+  home?: string | undefined
   /** Open a Tool argument path through the Host. */
   openFile: (path: string) => void
   /** Inspect this call in the trajectory view when available. */
@@ -43,10 +46,21 @@ export interface ToolCallOwnerProps {
 /** Full props of a registered atomic Tool view. */
 export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
 
+/** Injected Host description for POSIX home-path display. */
+export type ToolHostDescriptionInjected = {
+  hooks: {
+    /** Current generation's Host description, bound by the slot renderer. */
+    hostDescription: HostDescriptionSource
+  }
+}
+
 /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */
 export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'>
   & PropsRenderSlots<'tool.call.toolview'>
   & PropsLocale<'conversation'>
+  & InjectFace<ToolHostDescriptionInjected>
 
 /** Full props of the selected Tool output renderer in the details panel. */
-export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'>
+export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'>
+  & PropsLocale<'conversation'>
+  & InjectFace<ToolHostDescriptionInjected>

+ 3 - 1
packages/client/ui-tool/src/client/index.ts

@@ -1,3 +1,5 @@
 /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */
 export { apply, inject } from './apply.ts'
-export type { ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolTreeProps } from './contract/slots.ts'
+export type {
+  ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps,
+} from './contract/slots.ts'

+ 11 - 4
packages/client/ui-tool/src/client/tool/ToolCallTree.tsx

@@ -12,12 +12,13 @@ function callName(node: ToolCallBlock): string {
 
 /** One atomic call dispatched through the Tool-owned keyed slot. */
 const ToolCall = memo(function ToolCall({
-  renderSlot, callId, toolName, block, openFile, selected, cwd, inspectCall, t, children,
+  renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, t, children,
 }: Pick<ToolTreeProps, 'renderSlot' | 'openFile' | 'cwd' | 'inspectCall' | 't'> & {
   callId: string
   toolName: string
   block: ToolCallBlock
   selected: boolean
+  home?: string | undefined
   children?: ReactNode
 }) {
   const owner: ToolCallOwnerProps = useMemo(() => ({
@@ -26,8 +27,9 @@ const ToolCall = memo(function ToolCall({
     block,
     openFile,
     cwd,
+    home,
     inspect: () => { inspectCall(callId) },
-  }), [callId, toolName, block, openFile, cwd, inspectCall])
+  }), [callId, toolName, block, openFile, cwd, home, inspectCall])
   return (
     <div
       className={css.callRow}
@@ -45,9 +47,10 @@ const ToolCall = memo(function ToolCall({
 })
 
 const ToolCallBranch = memo(function ToolCallBranch({
-  renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t,
+  renderSlot, block, selectedCallId, cwd, home, openFile, inspectCall, t,
 }: Pick<ToolTreeProps, 'renderSlot' | 'selectedCallId' | 'cwd' | 'openFile' | 'inspectCall' | 't'> & {
   block: ToolCallBlock
+  home?: string | undefined
 }) {
   return (
     <ToolCall
@@ -58,6 +61,7 @@ const ToolCallBranch = memo(function ToolCallBranch({
       openFile={openFile}
       selected={block.callId === selectedCallId}
       cwd={cwd}
+      home={home}
       inspectCall={inspectCall}
       t={t}
     >
@@ -70,6 +74,7 @@ const ToolCallBranch = memo(function ToolCallBranch({
               block={child}
               selectedCallId={selectedCallId}
               cwd={cwd}
+              home={home}
               openFile={openFile}
               inspectCall={inspectCall}
               t={t}
@@ -88,8 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({
  * @returns the Tool call tree.
  */
 export function ToolCallTree({
-  renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t,
+  renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostDescription, t,
 }: ToolTreeProps) {
+  const home = useHostDescription(description => description?.home)
   const block = node.data.root
   return (
     <ToolCallBranch
@@ -97,6 +103,7 @@ export function ToolCallTree({
       block={block}
       selectedCallId={selectedCallId}
       cwd={cwd}
+      home={home}
       openFile={openFile}
       inspectCall={inspectCall}
       t={t}

+ 6 - 10
packages/client/ui-tool/src/client/tool/ToolDetails.tsx

@@ -9,20 +9,16 @@ import { resultText } from './models/tool-call-model.ts'
 import { webCardModel } from './models/web-card-model.ts'
 import css from './ToolDetails.module.css'
 
-/** Pure details-body inputs; framework session seats stay at the slot boundary. */
-interface ToolDetailsContentProps {
-  block: ToolDetailsProps['block']
-  cwd?: ToolDetailsProps['cwd']
-  t: ToolDetailsProps['t']
-}
-
 /**
  * Render the selected Tool call's structured output when its presentation
  * intent is known, otherwise preserve the flattened result text.
- * @param props - selected call slice, workspace root, and locale seat.
+ * @param props - selected call slice, workspace root, host home, and locale seat.
  * @returns the details output body.
  */
-export function ToolDetails({ block, cwd, t }: ToolDetailsContentProps) {
+export function ToolDetails({
+  block, cwd, useHostDescription, t,
+}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useHostDescription' | 't'>) {
+  const home = useHostDescription(description => description?.home)
   const terminal = terminalCardModel(block, cwd)
   if (terminal !== null) {
     return (
@@ -34,7 +30,7 @@ export function ToolDetails({ block, cwd, t }: ToolDetailsContentProps) {
       </>
     )
   }
-  const read = readCardModel(block, cwd)
+  const read = readCardModel(block, cwd, home)
   if (read !== null) return <ReadBlock {...read} className={css.read} />
   const diff = diffCardModel(block)
   if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />

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

@@ -13,6 +13,7 @@
  * until the result arrives.
  * @module
  */
+import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
 import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
 
@@ -52,14 +53,15 @@ export type ReadCardModel = Pick<ReadBlockProps, 'label' | 'lines' | 'totalLines
  *
  * The label is the read view's `title` when the tool supplied one (the
  * presentation contract's replacement-title rule), otherwise the file path
- * relativized to the session workspace so a workspace-rooted absolute path
- * displays the same short form the row summary shows.
+ * shortened the same way the row summary is: workspace-relative first, then
+ * POSIX `~` for a leftover host-home path.
  * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
  * @param sessionCwd - the session workspace root; a workspace-rooted absolute
  *   path label displays relative to it. Absent leaves the path as authored.
+ * @param home - host account home; a leftover POSIX home path displays as `~`.
  * @returns the read-card props, or null for the generic path.
  */
-export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCardModel | null {
+export function readCardModel(block: ToolCallBlock, sessionCwd?: string, home?: string): ReadCardModel | null {
   // Running has no result view; a read carries no content until execute returns.
   if (!('kind' in block)) return null
   const result = block.resultView?.card === 'read' ? block.resultView : null
@@ -68,7 +70,7 @@ export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCa
   // shape so the card never holds a reference into the runtime's cache.
   const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text }))
   return {
-    label: result.title ?? relativizeToCwd(result.path, sessionCwd),
+    label: result.title ?? abbreviateHomePath(relativizeToCwd(result.path, sessionCwd), home),
     lines,
     totalLines: result.totalLines,
     lang: result.lang,

+ 6 - 2
packages/client/ui-tool/src/client/tool/models/tool-call-model.ts

@@ -9,6 +9,7 @@
 // The block union's defining home is runtime (fold-product types); this
 // contract only forwards it (type-definition authority stays with the layer
 // that produces the values).
+import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 
 export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -206,16 +207,19 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
  * @param toolName - wire tool name (dispatch-supplied; survives windowless results).
  * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
  * @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
+ * @param home - host account home; a leftover POSIX home path displays as `~`.
  * @returns the row model.
  */
-export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel {
+export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string, home?: string): ToolRowModel {
   const variant = classifyTool(toolName)
   const done = 'kind' in block
   const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
   const state: ToolRowState = !done ? 'running'
     : block.error?.code === 'interrupted' ? 'stopped'
       : block.isError ? 'error' : 'ok'
-  const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd)
+  const base = argsRaw === ''
+    ? block.callId
+    : abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home)
   const toolTitle = TOOL_TITLES[toolName]
   // Others keeps the static "Tool call" title (figma literal); the real tool
   // name rides the mutable summary slot unless the tool owns a specific title.

+ 3 - 3
packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx

@@ -33,10 +33,10 @@ export interface GenericToolCardProps extends ToolCallOwnerProps {
   t: ToolTreeProps['t']
 }
 
-export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
-  const model = toolRowModel(toolName, block, cwd)
+export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, t }: GenericToolCardProps) {
+  const model = toolRowModel(toolName, block, cwd, home)
   const terminal = terminalCardModel(block, cwd)
-  const read = readCardModel(block, cwd)
+  const read = readCardModel(block, cwd, home)
   const diff = diffCardModel(block)
   const search = searchCardModel(block)
   const web = webCardModel(block)

+ 2 - 2
packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx

@@ -29,8 +29,8 @@ type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'>
  * model-facing error text through its Output section and its first line in the
  * collapsed summary instead.
  */
-export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: FileMutationRowProps) {
-  const model = toolRowModel(toolName, block, cwd)
+export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, t }: FileMutationRowProps) {
+  const model = toolRowModel(toolName, block, cwd, home)
   const diff = diffCardModel(block)
   return (
     <ToolRow

+ 3 - 3
packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx

@@ -24,9 +24,9 @@ type ReadRowProps = ToolCallViewProps & PropsLocale<'conversation'>
  * read card as the row's collapsed-by-default card body. The summary path is an
  * openable host link when the row names a single file.
  */
-export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) {
-  const model = toolRowModel(toolName, block, cwd)
-  const read = readCardModel(block, cwd)
+export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: ReadRowProps) {
+  const model = toolRowModel(toolName, block, cwd, home)
+  const read = readCardModel(block, cwd, home)
   return (
     <ToolRow
       t={t}

+ 9 - 0
packages/client/ui-tool/tests/read-card.client.spec.tsx

@@ -105,6 +105,15 @@ describe('readCardModel', () => {
       .toBe('/w/app/src/a.ts')
   })
 
+  it('abbreviates a leftover POSIX home path label', () => {
+    expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label)
+      .toBe('~/notes.md')
+    expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label)
+      .toBe('src/a.ts')
+    expect(readCardModel(settled({ resultView: resultRead({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label)
+      .toBe('C:\\Users\\u\\a.ts')
+  })
+
   it('carries an omitted language through as undefined', () => {
     const noLang = resultRead()
     delete (noLang as { lang?: string }).lang

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

@@ -44,6 +44,7 @@ function props(
     inspectCall: vi.fn(),
     forkAt: vi.fn(),
     fileMentions: vi.fn(),
+    useHostDescription: (selector => selector(undefined)) as ToolTreeProps['useHostDescription'],
     t,
   } as unknown as ToolTreeProps
 }

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

@@ -4,6 +4,7 @@ import type {
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts'
+import type { ToolDetailsProps } from '../src/client/contract/slots.ts'
 import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
 
 /** Framework session-area seat used by direct DetailsPanel tests. */
@@ -58,6 +59,11 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr
     // PropsRenderSlots keeps its key generic even for this one-key share;
     // recover the concrete owner selected by the adapter's fixed slot.
     const details = owner as unknown as DetailsToolOwnerProps
-    return <ToolDetails block={details.block} cwd={details.cwd} t={t} />
+    return <ToolDetails
+      block={details.block}
+      cwd={details.cwd}
+      useHostDescription={(selector => selector(undefined)) as ToolDetailsProps['useHostDescription']}
+      t={t}
+    />
   }
 }

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

@@ -137,6 +137,32 @@ describe('tool-call-model', () => {
     expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
   })
 
+  it('abbreviates leftover POSIX home paths after cwd relativization', () => {
+    const home = '/Users/u'
+    const cwd = '/tmp/ws'
+    expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u"}' }), cwd, home).summary).toBe('~')
+    expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/notes.md"}' }), cwd, home).summary)
+      .toBe('~/notes.md')
+    // Workspace-relative wins: a home-and-cwd descendant stays short, not `~/…`.
+    expect(toolRowModel(
+      'read',
+      running({ name: 'read', argsRaw: '{"path":"/Users/u/proj/src/a.ts"}' }),
+      '/Users/u/proj',
+      home,
+    ).summary).toBe('src/a.ts')
+    // Prefix boundary: `/Users/u2` is not under `/Users/u`.
+    expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u2/a.ts"}' }), cwd, home).summary)
+      .toBe('/Users/u2/a.ts')
+    expect(toolRowModel(
+      'read',
+      running({ name: 'read', argsRaw: '{"path":"C:\\\\Users\\\\u\\\\a.ts"}' }),
+      cwd,
+      home,
+    ).summary).toBe('C:\\Users\\u\\a.ts')
+    expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/a.ts"}' }), cwd).summary)
+      .toBe('/Users/u/a.ts')
+  })
+
   it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
     expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n  "a": 1\n}')
     expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')

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

@@ -14,6 +14,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../connection/tsconfig.client.json"
+    },
     {
       "path": "../runtime"
     },

+ 2 - 2
packages/client/ui-workspace/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-workspace/README.md
-README.md: 8878aa49dcccd60ddcde5f0a9563bbfbd969c9c0
-README.zh.md: c83f1c53d5471dd8d52b933163a573eb98a70c2e
+README.md: fb26b0386f729863514862ccb819cb3f99e96dc0
+README.zh.md: a32c5e2ca343c21b6a156da25807c5a96162e03f

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
packages/client/ui-workspace/README.md


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
packages/client/ui-workspace/README.zh.md


+ 3 - 0
packages/client/ui-workspace/package.json

@@ -32,6 +32,7 @@
   "dsh": {
     "client": {
       "inject": [
+        "@deepseek-ai/dsh-client-connection",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-runtime",
         "@deepseek-ai/dsh-client-ui-conversation",
@@ -49,6 +50,7 @@
     "clsx": "^2.0.0"
   },
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
@@ -58,6 +60,7 @@
     "react": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",

+ 7 - 1
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -218,6 +218,8 @@ type SessionTreeProps = Pick<
   'useSessions' | 'startSession' | 'open' | 'forkSession'
   | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
 > & {
+  /** Host account home for POSIX hover-path abbreviation. */
+  home?: string | undefined
   workspaces: readonly WorkspaceView[]
   /** Explicit persisted zero-or-five-session state by Workspace group. */
   groupExpansion: Readonly<Record<string, boolean>>
@@ -251,7 +253,7 @@ function SessionTree({
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
   insertWorkspaceBefore, insertSessionBefore, orderBy,
   groupExpansion, setGroupExpanded,
-  sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t,
+  sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t,
 }: SessionTreeProps) {
   const list = useSessions(s => s)
   const current = list.current
@@ -450,6 +452,7 @@ function SessionTree({
             >
               <ProjectRowItem
                 group={group}
+                home={home}
                 t={t}
                 onToggle={() => {
                   if (group.expanded) {
@@ -758,9 +761,11 @@ export function WorkspaceBrowser({
   searchSessions,
   searchResultLimit,
   useDirectoryFlow,
+  useHostDescription,
   renderSlot,
   t,
 }: WorkspaceBrowserProps) {
+  const home = useHostDescription(description => description?.home)
   const workspaces = useWorkspaces(state => state.items)
   const workspacePhase = useWorkspaces(state => state.phase)
   const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
@@ -1152,6 +1157,7 @@ export function WorkspaceBrowser({
                 insertWorkspaceBefore={insertWorkspaceBefore}
                 insertSessionBefore={insertSessionBefore}
                 orderBy={orderBy}
+                home={home}
                 t={t}
                 onRenameRequest={(workspaceId, currentTitle) => {
                   setRenameTarget({ workspaceId, currentTitle })

+ 9 - 0
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -22,6 +22,7 @@
  * 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'
 // Type-only: pull the owner SlotMap merges into programs that resolve the
 // runtime shares below.
@@ -90,6 +91,10 @@ export type DirectoryPickingHooks = {
  * browsing region drives.
  */
 export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
+  hooks: DirectoryPickingInjected['hooks'] & {
+    /** Current generation's Host description, bound by the slot renderer. */
+    hostDescription: HostDescriptionSource
+  }
   /**
    * Start a New Session in a Workspace: reuse-or-create its blank session and
    * open it; without an explicit workspace, inherit the current Session
@@ -144,6 +149,10 @@ export type WorkspaceBrowserProps =
   & PropsStore<ReturnType<typeof createWorkspaceViewStore>>
   & Omit<WorkspaceBrowserInjected, 'hooks'>
   & DirectoryPickingHooks
+  & {
+    /** Selector hook over the current generation's Host description. */
+    useHostDescription: SnapshotSelectorHook<HostDescription | undefined>
+  }
   & PropsLocale<'workspace'>
 
 /**

+ 10 - 2
packages/client/ui-workspace/src/client/index.ts

@@ -8,6 +8,7 @@
  * client half (see the contract module doc). Export discipline:
  * packages/client/AGENTS.md.
  */
+import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
 import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -42,7 +43,12 @@ const NS = 'workspace'
  * provides a waitable service. apply therefore depends on each slot
  * declaration through `slots.inject()` instead of assuming order.
  */
-export const inject = ['slots', 'sessions', 'workspaces', 'locale']
+export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection']
+
+const absentHostDescription: HostDescriptionSource = {
+  getSnapshot: () => undefined,
+  subscribe: () => () => {},
+}
 
 /**
  * Register the browser and picker once their slot declarations are on the
@@ -51,6 +57,8 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale']
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
+  const connection = ctx.get('connection') as ConnectionHandle
+  const hostDescription = connection.hostDescription ?? absentHostDescription
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
 
   const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => {
@@ -99,7 +107,7 @@ export function apply(ctx: ClientContext): void {
       await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
     },
     createWorkspace: input => ctx.workspaces.create(input),
-    hooks: { directoryFlow: browserFlowSource },
+    hooks: { directoryFlow: browserFlowSource, hostDescription },
   })
   const pickerInjected = (): WorkspacePickerInjected => ({
     createWorkspace: input => ctx.workspaces.create(input),

+ 12 - 3
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -13,6 +13,7 @@ import {
   IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
+import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client'
 import type { WorkspaceBrowserProps } from '../contract/slots.ts'
 import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
 import { relativeTime } from '../tree.ts'
@@ -50,7 +51,7 @@ function createdLabel(createdAt: number, t: RowTranslate): string {
   return t('hover.created', { time: `${date} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` })
 }
 
-/** Hover-card body: workspace title, full directory path, absolute creation time. */
+/** Hover-card body: workspace title, display directory path, absolute creation time. */
 function WorkspaceHoverContent({ label, cwd, createdAt, t }: {
   label: string
   cwd: string | undefined
@@ -104,10 +105,11 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
  * @param props.onToggle - expand/collapse the group.
  * @param props.onCreate - start a frontend Session inside this Workspace.
  * @param props.drag - optional workspace-row drag wiring.
+ * @param props.home - host account home for POSIX hover-path abbreviation.
  * @param props.t - the browser root's locale seat.
  * @returns the row element.
  */
-export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: {
+export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, t }: {
   group: GroupNode
   onToggle: () => void
   onCreate: () => void
@@ -115,6 +117,8 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }:
   actions?: { rename: () => void; delete: () => void } | undefined
   /** Present only for real Workspace rows in the grouped view. */
   drag?: WorkspaceRowDragProps | undefined
+  /** Host account home; POSIX home-rooted hover paths display as `~`. */
+  home?: string | undefined
   t: RowTranslate
 }) {
   const row = group
@@ -196,7 +200,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }:
   return (
     <HoverCard
       anchor={ownRow}
-      content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} t={t} />}
+      content={<WorkspaceHoverContent
+        label={row.label}
+        cwd={row.cwd === undefined ? undefined : abbreviateHomePath(row.cwd, home)}
+        createdAt={row.createdAt}
+        t={t}
+      />}
       disabled={menuOpen}
       copyText={row.cwd}
       copyLabel={t('copy')}

+ 5 - 1
packages/client/ui-workspace/tests/apply.client.spec.ts

@@ -36,6 +36,9 @@ async function bench() {
     create, startSession, rename, insertSessionBefore,
   } as never)
   ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never)
+  ctx.provide('connection', {
+    hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
+  } as never)
   const locale = new LocaleRuntime(ctx)
   ctx.provide('locale', locale)
   return {
@@ -54,7 +57,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void {
 
 describe('ui-workspace apply', () => {
   it('declares the services it drives', () => {
-    expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale'])
+    expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale', 'connection'])
   })
 
   it('registers browser and pickers for declarations arriving before or after apply', async () => {
@@ -126,6 +129,7 @@ describe('ui-workspace apply', () => {
     const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
     const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
     expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
+    expect(browser.hooks.hostDescription.getSnapshot()).toBeUndefined()
     expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
     // A flow occupant flips exactly its own surface, and the source notifies.
     const notified = vi.fn()

+ 3 - 0
packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx

@@ -31,6 +31,9 @@ beforeEach(() => { localStorage.clear() })
 /** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */
 async function createRuntime(): Promise<SlotTestRuntime> {
   const runtime = await SlotTestRuntime.create()
+  runtime.provide('connection', {
+    hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
+  } as never)
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.provide('locale', locale)
   runtime.slots.installLocale(locale)

+ 38 - 0
packages/client/ui-workspace/tests/rows.client.spec.tsx

@@ -304,6 +304,44 @@ describe('workspace browser rows', () => {
     }
   })
 
+  it('workspace hover card shows a POSIX home descendant as ~ and still copies the full path', async () => {
+    vi.useFakeTimers()
+    const writeText = vi.fn(async () => {})
+    const restoreClipboard = installClipboard(writeText)
+    try {
+      const group: GroupNode = {
+        key: 'project', workspaceId: wid('project'), cwd: '/home/u/Documents/project', createdAt: 0, label: 'Project',
+        sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
+      }
+      render(<ProjectRowItem group={group} home="/home/u" onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
+      fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
+      act(() => { vi.advanceTimersByTime(500) })
+      expect(screen.getByText('~/Documents/project')).toBeTruthy()
+      expect(screen.queryByText('/home/u/Documents/project')).toBeNull()
+      await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /home/u/Documents/project' })) })
+      expect(writeText).toHaveBeenCalledWith('/home/u/Documents/project')
+    } finally {
+      restoreClipboard()
+      vi.useRealTimers()
+    }
+  })
+
+  it('workspace hover card leaves a Windows path verbatim', async () => {
+    vi.useFakeTimers()
+    try {
+      const group: GroupNode = {
+        key: 'project', workspaceId: wid('project'), cwd: 'C:\\Users\\u\\project', createdAt: 0, label: 'Project',
+        sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
+      }
+      render(<ProjectRowItem group={group} home="C:\\Users\\u" onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
+      fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
+      act(() => { vi.advanceTimersByTime(500) })
+      expect(screen.getByText('C:\\Users\\u\\project')).toBeTruthy()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
   it('ungrouped bucket renders no workspace menu', () => {
     const group: GroupNode = {
       key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',

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

@@ -80,6 +80,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     insertSessionBefore: vi.fn(async () => {}),
     createWorkspace: vi.fn(async () => workspace('created', [])),
     useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
+    useHostDescription: selector => selector(undefined),
     renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
     t,
     ...overrides,

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

@@ -20,6 +20,9 @@
     {
       "path": "../ui-primitives"
     },
+    {
+      "path": "../connection/tsconfig.client.json"
+    },
     {
       "path": "../runtime"
     },

+ 2 - 2
packages/host/apiproxy/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/host/apiproxy/README.md
-README.md: 607cd4e4176631b64daf4a298b5d86a75ccdce68
-README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433
+README.md: 27efe5a75eb6947d71f95c0a60e590c15a35887c
+README.zh.md: 71f4e63a013170c6822a5c8dae28bc5f899b271a

+ 1 - 1
packages/host/apiproxy/README.md

@@ -50,7 +50,7 @@ A stale continuation discards every partial result, deduplication entry, and cur
 
 Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
 
-`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action.
+`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.home` is the host account home directory. The Web client uses it to display POSIX home-rooted paths as `~`; Windows values are still reported and are not abbreviated. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action.
 
 The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.
 

+ 1 - 1
packages/host/apiproxy/README.zh.md

@@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
 
 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
 
-`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。
+`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.home` 是宿主账户的家目录。Web 客户端用它把 POSIX 家目录路径显示为 `~`;Windows 值仍会上报,但不会缩写。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。
 
 `agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录的工具调用,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
 

+ 2 - 0
packages/host/apiproxy/src/api-proxy.ts

@@ -5,6 +5,7 @@
 
 import { randomUUID } from 'node:crypto'
 import { mkdir, stat } from 'node:fs/promises'
+import { homedir } from 'node:os'
 import { dirname } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import { installModelSelection } from '@deepseek-ai/dsh-agent'
@@ -2874,6 +2875,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           provider: selection.provider,
           model: selection.model,
           attachedSessions: ctx.agents.list().length,
+          home: homedir(),
           canOpenPath: canOpenPaths(),
         }))
       },

+ 1 - 0
packages/host/apiproxy/src/api/host.schema.ts

@@ -17,6 +17,7 @@ export const hostDescribeValueSchema = z.object({
   provider: z.string().optional(),
   model: z.string().optional(),
   attachedSessions: z.number().int().nonnegative(),
+  home: z.string(),
   canOpenPath: z.boolean(),
 }) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
 

+ 2 - 0
packages/host/apiproxy/src/api/host.ts

@@ -41,6 +41,7 @@ export interface HostApi {
    * applied when a new agent doesn't specify them explicitly, absent when the host configures
    * no explicit default (the adapter falls back internally);
    * attachedSessions = count of currently attached sessions (those with a live agent);
+   * home = the host account home directory (Web display abbreviation on POSIX);
    * canOpenPath = whether this deployment can hand a path to a user-visible native desktop.
    */
   describe(request: RpcRequest<{}>): Promise<RpcResponse<{
@@ -49,6 +50,7 @@ export interface HostApi {
     provider?: string
     model?: string
     attachedSessions: number
+    home: string
     canOpenPath: boolean
   }>>
 

+ 2 - 1
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts

@@ -1,5 +1,5 @@
 import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
-import { tmpdir } from 'node:os'
+import { homedir, tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
@@ -234,6 +234,7 @@ describe('host.openPath', () => {
     const headless = await harness(undefined, undefined, { canOpenPath: () => false })
     expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true)
     expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false)
+    expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir())
   })
 
   it('opens through the injected native boundary', async () => {

+ 1 - 1
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -72,7 +72,7 @@ function scriptedApi(overrides: {
     },
     host: {
       describe: r => ok(r, {
-        version: '0-test', cwd: '/t', attachedSessions: 0, canOpenPath: true,
+        version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
       }),
       pickDirectory: r => ok(r, { path: null }),
       listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }),

+ 1 - 1
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -143,7 +143,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
           rpcId: request.rpcId,
           result: {
             ok: true,
-            value: { version: 'v', cwd: '/w', attachedSessions: 0, canOpenPath: true },
+            value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true },
           },
         }
       },

+ 5 - 2
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -312,15 +312,18 @@ describe('host domain schemas', () => {
   it('validates describe request/value', () => {
     expect(hostDescribeRequestSchema.parse({})).toEqual({})
     const value = hostDescribeValueSchema.parse({
-      version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true,
+      version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, home: '/h', canOpenPath: true,
     })
     expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true })
     expect(hostDescribeValueSchema.parse({
-      version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: false,
+      version: '1', cwd: '/x', attachedSessions: 0, home: '/h', canOpenPath: false,
     }).provider).toBeUndefined()
     expect(() => hostDescribeValueSchema.parse({
       version: '1', cwd: '/x', attachedSessions: 0,
     })).toThrow()
+    expect(() => hostDescribeValueSchema.parse({
+      version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true,
+    })).toThrow()
   })
 
   it('validates the browse listing/creation payloads', () => {

+ 3 - 0
pnpm-lock.yaml

@@ -3032,6 +3032,9 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale

Някои файлове не бяха показани, защото твърде много файлове са промени