This commit is contained in:
07akioni
2026-08-18 20:14:40 +08:00
parent 51c86af1e9
commit f15ca23386
9 changed files with 66 additions and 31 deletions
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -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()
})
})
@@ -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}
/>
}
@@ -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')
@@ -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'>
/**
@@ -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([]),