mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(ui-tool): integrate connection handling for POSIX home path abbreviation
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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',
|
||||
|
||||
@@ -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) })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
hover=~/Documents/project
|
||||
copy=Copy: /home/fixture/Documents/project
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 流,两者没有相对顺序。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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` 注册项。
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../connection/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ The browser renders grouped or flat Session rows from the global runtime hooks a
|
||||
|
||||
Collapsed search is one header action beside the view and add actions. In the rail, add and search render as 36px controls on the shell's shared horizontal entry path. Activating search expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail shows a POSIX home or descendant as `~` / `~/…` and leaves a Windows path verbatim. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
|
||||
|
||||
Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
折叠搜索是视图和添加操作旁的一枚区头按钮。在轨道中,添加和搜索会渲染为沿外壳共用横向进入路径移动的 36px 控件。激活搜索后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情把 POSIX 家目录及其后代显示为 `~`/`~/…`,Windows 路径保持原样。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。
|
||||
|
||||
Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../connection/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -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'>>>
|
||||
|
||||
|
||||
@@ -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
|
||||
}>>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user