feat: click file name to open file in toolcall, remove hover bg of toolcall, do not trigger sidebar any more (follow designer's instruction)

This commit is contained in:
07akioni
2026-07-28 14:24:41 +08:00
parent 4c6fb8b957
commit b926044c13
50 changed files with 649 additions and 172 deletions

View File

@@ -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-07-28-tool-call-file-open-in-os.md
2026-07-28-tool-call-file-open-in-os.md: a2c9b52507d32c2d851f811f0ecdd878a60b1e1c
2026-07-28-tool-call-file-open-in-os.zh.md: efb4c39503d9de71a9d773bdae7fac4fb2b08ee3

View File

@@ -0,0 +1,30 @@
# Agent Note: Tool-call file open in OS
Status: implemented
English | [中文](2026-07-28-tool-call-file-open-in-os.zh.md)
## Problem
Chat tool rows treated the whole summary line as a click target that opened the right-hand details panel, with a hover background on the row. For filesystem tools the useful action is opening the mentioned file in the operating system's default application, not inspecting the raw tool payload in a sidebar.
## Decision
File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as hover-underline links with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspacesService.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them.
`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, `xdg-open` on Linux. The opener is injectable for tests. URL-only read args (`web_fetch`) are not file links.
## Alternatives considered
- Keep row-click details and add a separate file affordance — rejected; the product ask replaces the row gesture with the file link.
- Open files inside an in-app preview — rejected; the ask is the OS default application.
- Reuse `host.pickDirectory`'s timeout exemption — unnecessary; path open hand-off completes quickly under the normal unary deadline.
## Consequences
Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`.
## Risks
- Linux hosts without `xdg-open` fail the RPC; the chat row stays silent while the host returns an internal error.
- Relative paths without a session cwd are forwarded verbatim and may fail on the host.

View File

@@ -0,0 +1,30 @@
# Agent Note: 在工具调用中用系统应用打开文件
Status: implemented
[English](2026-07-28-tool-call-file-open-in-os.md) | 中文
## Problem
聊天工具行把整行摘要当作点击目标,点击后打开右侧 details 面板,并带有整行悬停背景。对文件系统工具而言,有用的动作是用操作系统默认应用打开所涉文件,而不是在侧栏里查看原始工具载荷。
## Decision
文件工具的路径摘要(`read``write``edit` 参数中的 `path``file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径相对会话 cwd 解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。
`host.openPath` 是特权一元 RPC仅接受来自回环、同源浏览器请求`host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开macOS 为 `open`Windows 为 PowerShell `Invoke-Item`Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。
## Alternatives considered
- 保留整行点击打开 details另加文件入口 — 否决;产品要求用文件链接替换整行手势。
- 在应用内预览文件 — 否决;要求是操作系统默认应用。
- 复用 `host.pickDirectory` 的超时豁免 — 不必要;打开路径的交接在常规一元截止时间内即可完成。
## Consequences
点击工具行中的文件路径会在宿主上打开该路径。非文件工具行是惰性摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`
## Risks
- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回 internal 错误。
- 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。

View File

@@ -776,6 +776,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
pickDirectory: request => ok(request, { path: null }),
openPath: request => ok(request, { opened: true as const }),
},
workspace: {
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
@@ -1027,6 +1028,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)

View File

@@ -26,7 +26,8 @@ export function apply(ctx: Context): void {
path: API_PATH,
handler: async (req, res) => {
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
if (pathname === `${API_PATH}/host.pickDirectory`
if ((pathname === `${API_PATH}/host.pickDirectory`
|| pathname === `${API_PATH}/host.openPath`)
&& !isTrustedNativeDialogRequest(req)) {
res.writeHead(403)
res.end('forbidden')

View File

@@ -1,4 +1,4 @@
/** Trust check for browser requests that can open an operating-system dialog. */
/** Trust check for browser requests that can invoke privileged native host actions. */
import type { IncomingHttpHeaders } from 'node:http'

View File

@@ -66,6 +66,8 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -88,6 +90,7 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
readonly workspace: IApiClient['workspace'] = {

View File

@@ -28,22 +28,24 @@ describe('connection node half', () => {
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
let status: number | undefined
let body: unknown
const deniedRequest = {
url: '/api/host.pickDirectory',
headers: {
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
},
socket: { remoteAddress: '192.168.1.8' },
} as unknown as IncomingMessage
const deniedResponse = {
writeHead(value: number) { status = value; return this },
end(value?: unknown) { body = value; return this },
} as unknown as ServerResponse
await routes[0]!.handler(deniedRequest, deniedResponse)
expect(status).toBe(403)
expect(body).toBe('forbidden')
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
let status: number | undefined
let body: unknown
const deniedRequest = {
url,
headers: {
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
},
socket: { remoteAddress: '192.168.1.8' },
} as unknown as IncomingMessage
const deniedResponse = {
writeHead(value: number) { status = value; return this },
end(value?: unknown) { body = value; return this },
} as unknown as ServerResponse
await routes[0]!.handler(deniedRequest, deniedResponse)
expect(status).toBe(403)
expect(body).toBe('forbidden')
}
await fiber.dispose()
expect(routes).toHaveLength(0)

View File

@@ -182,6 +182,17 @@ export class WorkspacesService {
return response.result.value.path
}
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.
*/
async openPath(path: string): Promise<void> {
const response = await this.api.host.openPath({ path })
if (!response.result.ok) {
throw new Error(`path open failed: ${response.result.error.message}`)
}
}
/**
* Rename a Workspace.
* @param workspaceId - target workspace.

View File

@@ -84,6 +84,8 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -106,6 +108,7 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))

View File

@@ -236,6 +236,17 @@ describe('WorkspacesService', () => {
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
})
it('opens a filesystem path through the host without local state', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()

View File

@@ -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-conversation/README.md
README.md: 56a445ccfa86e0b11cf5aefc37819a30746f0739
README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070
README.md: a04c20f225c731581accbe8c12c52a5e7597029a
README.zh.md: f9e6a635ea6090c87a66f029785af214025b9bda

View File

@@ -8,9 +8,9 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.

View File

@@ -8,9 +8,9 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openDetails``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是常驻的计划条它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。

View File

@@ -7,6 +7,7 @@ import type { ViewTab } from './contract/views.ts'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { InputHub } from './input/hub.ts'
@@ -165,6 +166,13 @@ export function apply(ctx: Context): void {
actions.select(target)
layout.openDetails()
},
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
// Host/OS open failures stay silent in the chat row; the native
// app surfaces its own error dialog when the path is unusable.
})
},
loadOlder: () => { void scoped.loadOlder() },
}
},

View File

@@ -25,7 +25,6 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { SelectionTarget } from '../contract/views.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -36,7 +35,7 @@ import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
type OpenFile = (path: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
@@ -49,19 +48,17 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: {
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected }: {
renderSlot: RenderToolRow
node: CodeSubCall
onOpenDetails: OpenDetails
openFile: OpenFile
selected: boolean
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const seq = settled ? node.seq : node.time
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
}), [node, toolName, seq, onOpenDetails])
callId: node.callId, toolName, block: node, openFile,
}), [node, toolName, openFile])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -77,14 +74,12 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s
* GenericToolCard at this render site. A `run_code` call additionally
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: {
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId }: {
renderSlot: RenderToolRow
callId: string
toolName: string
block: ToolResultNode | RunningToolCall
/** Surface seq for finalized results; the call's turn for running calls. */
seq: number
onOpenDetails: OpenDetails
openFile: OpenFile
selected: boolean
/** `run_code` sub-dispatches in dispatch order (reference-stable per
* parent; running entries settle in place); undefined for ordinary calls. */
@@ -93,9 +88,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
selectedCallId?: string | undefined
}) {
const owner = useMemo(() => ({
callId, toolName, block,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, onOpenDetails])
callId, toolName, block, openFile,
}), [callId, toolName, block, openFile])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -109,7 +103,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
key={node.callId}
renderSlot={renderSlot}
node={node}
onOpenDetails={onOpenDetails}
openFile={openFile}
selected={node.callId === selectedCallId}
/>
))}
@@ -120,10 +114,10 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
})
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
openFile: OpenFile
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
selectedCallId: string | undefined
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
@@ -138,8 +132,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
seq={node.seq}
onOpenDetails={onOpenDetails}
openFile={openFile}
selected={node.callId === selectedCallId}
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
@@ -167,7 +160,7 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
export function ChatView({ useSession, useStore, renderSlot, openFile, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
@@ -265,7 +258,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
key={item.key}
renderSlot={renderSlot}
results={item.results}
onOpenDetails={openDetails}
openFile={openFile}
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
/>
@@ -304,8 +297,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={openDetails}
openFile={openFile}
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}

View File

@@ -25,8 +25,9 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
others: <IconSparkle16 />,
}
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
export function GenericToolCard({ toolName, block, openFile }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block)
const singleFile = model.filePath !== undefined
return (
<ToolRow
variant={model.variant}
@@ -34,9 +35,11 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr
icon={VARIANT_ICONS[model.variant]}
title={model.title}
summary={model.summary}
body={model.body}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
state={model.state}
onOpenDetails={openDetails}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
/>
)
}

View File

@@ -13,13 +13,9 @@
min-width: 0;
}
.row[data-clickable] {
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
.row[data-expandable] {
cursor: pointer;
border-radius: 6px;
}
.row[data-clickable]:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading {
@@ -92,6 +88,29 @@ button.leading {
color: var(--dsw-alias-label-tertiary);
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
.body {
padding: 4px 0 4px 22px;

View File

@@ -2,7 +2,8 @@
// 16px leading slot (state dot / tool icon, chevron when expanded) + title +
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
// no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner.
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -24,8 +25,13 @@ export interface ToolRowProps {
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/** Selection handoff (row click), already bound to this call by the owner. */
onOpenDetails?: (() => void) | undefined
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
* renders as a hover-underline link that opens the host default app.
*/
filePath?: string | undefined
/** Open the path with the host OS default application (already cwd-resolved). */
onOpenFile?: ((path: string) => void) | undefined
}
/** Leading-slot state substitution: the tool icon yields to the state semantic
@@ -48,10 +54,15 @@ export function ToolRow({
body,
state,
expandOnRowClick = false,
onOpenDetails,
filePath,
onOpenFile,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const expandable = body !== null
// A row that names a single file keeps one interaction (open that path);
// args expand is off whether or not the open callback is wired yet.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = body !== null && !singleFile
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => {
@@ -66,15 +77,19 @@ export function ToolRow({
event.preventDefault()
toggleExpand()
}
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
}
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
className={css.row}
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
data-expandable={rowExpands || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : onOpenDetails}
onClick={rowExpands ? toggleExpand : undefined}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable && !rowExpands ? (
@@ -95,7 +110,17 @@ export function ToolRow({
{!open && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{summary}</span>
{fileLink ? (
<button
type="button"
className={css.fileLink}
onClick={openFile}
>
{summary}
</button>
) : (
<span className={css.summary}>{summary}</span>
)}
</>
)}
</div>

View File

@@ -143,8 +143,11 @@ export interface ToolRowOwnerProps {
toolName: string
/** Frozen call slice: the running call or the settled result node. */
block: ToolCallBlock
/** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails: () => void
/**
* Open a tool-arg filesystem path with the host OS default application.
* The chat view resolves relative paths against the session cwd.
*/
openFile: (path: string) => void
}
/**
@@ -276,6 +279,11 @@ export type ConversationSessionSlotProps =
export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails: (target: SelectionTarget) => void
/**
* Open a tool-arg filesystem path with the host OS default application
* (relative paths resolve against the session cwd).
*/
openFile: (path: string) => void
loadOlder: () => void
}

View File

@@ -62,6 +62,12 @@ export interface ToolRowModel {
variant: ToolRowVariant
title: string
summary: string
/**
* Filesystem path from args (`path` / `file_path`) when the row is a file
* tool; absent for URL reads and non-file tools. The chat view resolves
* relative values against the session cwd before opening.
*/
filePath: string | undefined
/** Expanded-body text (pretty args); null = row not expandable. */
body: string | null
state: ToolRowState
@@ -113,6 +119,35 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
return firstLine(argsRaw)
}
/** Path keys only — never `url` (web_fetch lands on the read variant). */
const FILE_PATH_KEYS = ['path', 'file_path'] as const
/** File-tool variants whose summary may be an openable workspace path. */
const FILE_PATH_VARIANTS: ReadonlySet<ToolRowVariant> = new Set(['read', 'write', 'edit'])
function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined {
if (!FILE_PATH_VARIANTS.has(variant)) return undefined
const parsed = parseArgs(argsRaw)
if (typeof parsed !== 'object' || parsed === null) return undefined
const picked = pickString(parsed as Record<string, unknown>, FILE_PATH_KEYS)
return picked === undefined ? undefined : firstLine(picked)
}
/**
* Resolve a tool-arg path against the session cwd for host.openPath.
* Absolute POSIX/Windows paths pass through; relative paths join under cwd.
* @param cwd - session working directory (may be absent for ungrouped sessions).
* @param path - path as carried in tool args.
* @returns a host-facing path string.
*/
export function resolveToolPath(cwd: string | undefined, path: string): string {
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
if (cwd === undefined || cwd === '') return path
const base = cwd.replace(/[/\\]+$/, '')
const rel = path.replace(/^[/\\]+/, '')
return `${base}/${rel}`
}
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
if (argsRaw === '') return null
const parsed = parseArgs(argsRaw)
@@ -150,6 +185,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod
variant,
title: toolTitle ?? VARIANT_TITLES[variant],
summary,
filePath: deriveFilePath(variant, argsRaw),
body: deriveBody(variant, argsRaw),
state,
}

View File

@@ -5,12 +5,6 @@
align-items: center;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
}
.root:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading {

View File

@@ -30,7 +30,7 @@ function stateStatus(state: ToolRowState): string | null {
}
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
const status = stateStatus(model.state)
@@ -40,8 +40,6 @@ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
data-variant="bash"
data-state={model.state}
data-clickable
onClick={openDetails}
>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}

View File

@@ -6,12 +6,6 @@
align-items: center;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading {

View File

@@ -5,7 +5,6 @@
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line. Chrome matches ToolRow (figma 780:53675).
import type { KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
@@ -51,29 +50,18 @@ function leadingFor(state: ToolRowState) {
}
}
/** One-line plan update row (click opens the raw args in details). Non-ok
* execution states keep the generic row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
/** One-line plan update row. Non-ok execution states keep the generic row's
* dot semantics — a cancelled call wrote no todo/write, so it must not read
* as a completed update. */
export function TodoRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
// Button semantics, not a <button>: the row carries inline spans a button
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
openDetails()
}
return (
<div
className={css.row}
data-sample="todo-row"
data-state={model.state}
role="button"
tabIndex={0}
onClick={openDetails}
onKeyDown={openFromKeyboard}
>
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
<span className={css.title}></span>

View File

@@ -106,6 +106,7 @@ async function bench() {
const workspacesFake = {
list: workspaceStore,
connectWorkspace: vi.fn(async () => ROOT),
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspacesFake)
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
@@ -258,6 +259,15 @@ describe('conversation slot inject surface', () => {
expect(conv.instance).toBe(instance)
})
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
const b = await bench()
const { injected } = b.chatViewSurface(ROOT)
injected.openFile('src/a.ts')
await vi.waitFor(() => {
expect(b.workspacesFake.openPath).toHaveBeenCalledWith('/proj/src/a.ts')
})
})
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)

View File

@@ -47,6 +47,7 @@ async function bench() {
ctx.provide('workspaces', {
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('locale', { bind: () => (key: string) => key })

View File

@@ -5,7 +5,7 @@
// always-visible nested rows through the SAME keyed toolview hole — the bash
// sub-call lands in the bash sample plugin's registration exactly like a
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
// and a sub-row click opens details for the sub-callId. Running parents
// and a file sub-row click opens the host path. Running parents
// (runningCalls) nest their so-far dispatches the same way.
import { Context } from 'cordis'
@@ -110,14 +110,16 @@ async function bench(snapshot: ConversationSnapshot) {
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('workspaces', {
const workspaces = {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspaces)
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
@@ -132,7 +134,7 @@ async function bench(snapshot: ConversationSnapshot) {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, layout }
return { ctx, slots, fiber, session, layout, workspaces }
}
function mountApp(slots: SlotsService) {
@@ -216,15 +218,21 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(nested).not.toBeNull()
})
it('a sub-row click opens details for the sub-callId', async () => {
it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt')
})
view.getByText('List notes').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
expect(b.layout.openDetails).not.toHaveBeenCalled()
})
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {

View File

@@ -5,7 +5,7 @@
// standard useSessions kit (no registry predicates — tool ring dissolved).
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { act, cleanup, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -133,10 +133,9 @@ describe('bash sample row', () => {
const rowProps = (sessionId: SessionId, over?: {
store?: ReturnType<typeof listStore>
openDetails?: () => void
}): ToolRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openDetails: over?.openDetails ?? vi.fn(),
openFile: vi.fn(),
sessionId,
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
} as unknown as ToolRowProps)
@@ -169,21 +168,17 @@ describe('bash sample row', () => {
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
const openGlobal = vi.fn()
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
it('summarizes as Bash · description on both arms without row click targets', () => {
const global = render(<BashRow {...rowProps(ROOT)} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Bash')
expect(globalRow.textContent).toContain('Build')
fireEvent.click(globalRow)
expect(openGlobal).toHaveBeenCalledTimes(1)
const openScoped = vi.fn()
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
expect(globalRow.getAttribute('data-clickable')).toBeNull()
const scoped = render(<BashRow {...rowProps(CHILD)} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Bash')
expect(scopedRow.textContent).toContain('Build')
fireEvent.click(scopedRow)
expect(openScoped).toHaveBeenCalledTimes(1)
expect(scopedRow.getAttribute('data-clickable')).toBeNull()
})
})

View File

@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
@@ -64,6 +64,22 @@ describe('tool-call-model', () => {
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
})
it('exposes filePath for path/file_path args and skips URL-only reads', () => {
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('web_fetch', running({ name: 'web_fetch', argsRaw: '{"url":"https://example.com"}' })).filePath)
.toBeUndefined()
expect(toolRowModel('bash', running()).filePath).toBeUndefined()
})
it('resolveToolPath joins relative paths under cwd and passes absolute through', () => {
expect(resolveToolPath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
expect(resolveToolPath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
expect(resolveToolPath(undefined, 'src/a.ts')).toBe('src/a.ts')
expect(resolveToolPath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\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')
@@ -139,13 +155,34 @@ describe('ToolRow', () => {
expect(view.queryByTestId('tool-icon')).not.toBeNull()
})
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
const open = vi.fn()
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
const view = render(
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
)
fireEvent.click(view.getByText('src/a.ts'))
expect(open).toHaveBeenCalledWith('src/a.ts')
// Only the path link is a button — no args-expand affordance on file rows.
expect(view.container.querySelectorAll('button')).toHaveLength(1)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByText(/"a": 1/)).toBeNull()
})
it('a single-file path disables expand even when onOpenFile is absent', () => {
const view = render(
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
)
expect(view.container.querySelector('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
fireEvent.click(view.getByText('作文.md'))
expect(view.queryByText(/"a": 1/)).toBeNull()
})
it('non-file rows do not open anything when the summary is clicked', () => {
const open = vi.fn()
const view = render(<ToolRow {...rowProps} onOpenFile={open} />)
fireEvent.click(view.getByText('List files'))
expect(open).toHaveBeenCalledTimes(1)
fireEvent.click(view.container.querySelector('button')!)
expect(open).toHaveBeenCalledTimes(1)
expect(open).not.toHaveBeenCalled()
})
})
@@ -170,7 +207,7 @@ describe('ThinkRow', () => {
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
callId: 'c1', toolName, block, openDetails: vi.fn(),
callId: 'c1', toolName, block, openFile: vi.fn(),
})
it('renders the classified variant row from the frozen slice', () => {
@@ -215,10 +252,15 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('row click reaches openDetails', () => {
const p = props('bash', result())
const view = render(<GenericToolCard {...p} />)
fireEvent.click(view.getByText('List files'))
expect(p.openDetails).toHaveBeenCalledTimes(1)
it('file-path summary click reaches openFile; bash summary does not', () => {
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
const fileView = render(<GenericToolCard {...file} />)
fireEvent.click(fileView.getByText('src/x.ts'))
expect(file.openFile).toHaveBeenCalledWith('src/x.ts')
const bash = props('bash', result())
const bashView = render(<GenericToolCard {...bash} />)
fireEvent.click(bashView.getByText('List files'))
expect(bash.openFile).not.toHaveBeenCalled()
})
})

View File

@@ -120,14 +120,16 @@ async function bench(nodes: ToolResultNode[]) {
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
const workspaces = {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspaces)
ctx.provide('layout', layout)
ctx.provide('locale', { bind: () => (key: string) => key })
@@ -142,7 +144,7 @@ async function bench(nodes: ToolResultNode[]) {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, list, layout }
return { ctx, slots, fiber, session, list, layout, workspaces }
}
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
@@ -185,11 +187,22 @@ describe('keyed toolview hole through the real machinery', () => {
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
const view = mountApp(b.slots)
view.getByText('src/a.ts').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(b.workspaces.openPath).toHaveBeenCalledWith('src/a.ts')
})
})
it('bash summary clicks do not open details or host paths', async () => {
const b = await bench([toolResult(3, 'c1', 'bash')])
const view = mountApp(b.slots)
view.getByText('Build').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
expect(b.layout.openDetails).not.toHaveBeenCalled()
expect(b.workspaces.openPath).not.toHaveBeenCalled()
})
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
@@ -267,6 +280,7 @@ describe('registrant load-order seam', () => {
}),
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('locale', { bind: () => (key: string) => key })

View File

@@ -88,6 +88,7 @@ function emptyWorkspaces() {
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => void>()
const loadOlder = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
@@ -112,10 +113,11 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
renderSlot,
SessionProvider: SessionProviderStub,
openDetails,
openFile,
loadOlder,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, loadOlder, setSelection }
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
}
describe('chat-flow derivation', () => {
@@ -265,16 +267,31 @@ describe('ChatView', () => {
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
it('clicking a bash summary does not open details; selection still marks data-selected', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('run a'))
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
expect(h.openDetails).not.toHaveBeenCalled()
expect(h.openFile).not.toHaveBeenCalled()
expect(view.container.querySelector('[data-selected]')).toBeNull()
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
})
it('clicking a file-tool path summary opens the host file, not details', () => {
const h = makeHarness({
nodes: [{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1',
call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' },
callTime: 2_500, content: [], isError: false, callView: null, resultView: null,
}],
})
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('src/a.ts'))
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
expect(h.openDetails).not.toHaveBeenCalled()
})
it('running calls render as a live tool group with the running state', () => {
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
const view = render(<h.ChatView {...h.props} />)

View File

@@ -82,7 +82,7 @@ describe('tails', () => {
content: [], isError: false, callView: null, resultView: null,
}
const props: ToolRowOwnerProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(),
}
const view = render(<GenericToolCard {...props} />)
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
@@ -99,7 +99,7 @@ describe('tails', () => {
phase: 'ready',
})
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps)

View File

@@ -96,10 +96,10 @@ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResult
content: [], isError: false, callView: null, resultView: null, ...over,
})
function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
function rowProps(block: unknown): ToolRowProps {
return {
callId: 'c1', toolName: 'todo_write', block,
openDetails,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
} as unknown as ToolRowProps
@@ -140,27 +140,10 @@ describe('TodoRow', () => {
expect(screen.getByText('todo_write · not json')).toBeTruthy()
})
it('falls back when parsed args carry no todos array, and click opens details', () => {
const openDetails = vi.fn()
render(<TodoRow {...rowProps(resultNode('{"other":1}'), openDetails)} />)
it('falls back when parsed args carry no todos array and stays non-interactive', () => {
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
fireEvent.click(screen.getByText('更新任务清单'))
expect(openDetails).toHaveBeenCalledTimes(1)
})
it('opens details from the keyboard on Enter and Space, ignoring other keys', () => {
const openDetails = vi.fn()
render(<TodoRow {...rowProps(resultNode(ARGS), openDetails)} />)
const row = screen.getByRole('button')
expect(row.getAttribute('tabindex')).toBe('0')
fireEvent.keyDown(row, { key: 'Enter' })
fireEvent.keyDown(row, { key: ' ' })
expect(openDetails).toHaveBeenCalledTimes(2)
// Space must not also scroll the flow: the handler claims the event.
expect(fireEvent.keyDown(row, { key: ' ' })).toBe(false)
fireEvent.keyDown(row, { key: 'a' })
fireEvent.keyDown(row, { key: 'ArrowDown' })
expect(openDetails).toHaveBeenCalledTimes(3)
expect(screen.queryByRole('button')).toBeNull()
})
it.each([

View File

@@ -49,6 +49,8 @@ describe('view-ring type negatives (compile-time; body never runs)', () => {
const chatProps = (props: ChatViewSlotProps): ReactNode => {
// @ts-expect-error openDetails takes a SelectionTarget, not a string
props.openDetails('nope')
// @ts-expect-error openFile takes a path string, not a SelectionTarget
props.openFile({ turnSeq: 1, callId: 'c' })
return null
}
void chatProps

View File

@@ -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: d6db9a9541b0727b61dbe501f7234564ffef139e
README.zh.md: 4175c8fdb98aad2882718a2c95cd9e45825d787d
README.md: 33a4752f97c17e7879dff11a33acc93e335496b7
README.zh.md: 45aee0225717ad8a4d9b923067f196535c7ab3f9

View File

@@ -18,6 +18,8 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.

View File

@@ -18,6 +18,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具macOS 使用 `osascript`Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`Linux 使用 Zenity并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。

View File

@@ -40,6 +40,7 @@ import type {
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { pickNativeDirectory } from './native-directory-picker.ts'
import { openNativePath } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -203,6 +204,8 @@ export interface ApiProxyDefaults {
workspaceRoot: string
/** Native single-directory picker; injectable for carrier tests. */
pickDirectory?: (signal: AbortSignal) => Promise<string | null>
/** Native open-with-default-application; injectable for carrier tests. */
openPath?: (path: string, signal: AbortSignal) => Promise<void>
}
/** The tool/call payload fields the presenter path reads. */
@@ -1012,6 +1015,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
},
async openPath(request, signal) {
try {
const open = defaults.openPath
?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal))
await open(request.payload.path, signal)
return ok(request, { opened: true as const })
} catch (error: unknown) {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'path open was aborted',
details: {},
})
}
return err(request, {
code: 'internal',
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
},
},
commands: {

View File

@@ -25,3 +25,13 @@ export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType<W
export const hostPickDirectoryValueSchema = z.object({
path: z.string().nullable(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>
/** host.openPath request payload. */
export const hostOpenPathRequestSchema = z.object({
path: z.string().min(1),
}) satisfies z.ZodType<Wire<RequestPayload<'host.openPath'>>>
/** host.openPath response value. */
export const hostOpenPathValueSchema = z.object({
opened: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'host.openPath'>>>

View File

@@ -28,4 +28,14 @@ export interface HostApi {
request: RpcRequest<{}>,
signal: AbortSignal,
): Promise<RpcResponse<{ path: string | null }>>
/**
* Open a filesystem path with the operating system's default application
* (Finder / Explorer / xdg-open hand-off). The browser carrier restricts this
* privileged method to loopback, same-origin requests.
*/
openPath(
request: RpcRequest<{ path: string }>,
signal: AbortSignal,
): Promise<RpcResponse<{ opened: true }>>
}

View File

@@ -26,6 +26,7 @@ export interface RpcMethodMap {
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.openPath': HostApi['openPath']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']

View File

@@ -13,7 +13,9 @@ import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
import { hostDescribeValueSchema, hostPickDirectoryValueSchema } from '../api/host.schema.ts'
import {
hostDescribeValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
} from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
sessionCreateValueSchema,
@@ -61,6 +63,7 @@ export interface IApiClient {
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
}
workspace: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
@@ -98,6 +101,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.openPath': hostOpenPathValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
@@ -305,6 +309,7 @@ export abstract class AbstractApiClient implements IApiClient {
// A native system dialog is user-paced and may legitimately stay open
// longer than the normal unary deadline. Caller/connection aborts remain.
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
}
readonly workspace: IApiClient['workspace'] = {

View File

@@ -23,7 +23,9 @@ import {
sessionPromptRequestSchema,
sessionSelectModelRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostPickDirectoryRequestSchema } from '../api/host.schema.ts'
import {
hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema,
} from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
workspaceDeleteRequestSchema,
@@ -60,6 +62,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },

View File

@@ -0,0 +1,78 @@
/** Cross-platform open-with-default-application used by the local GUI carrier. */
import { execFile } from 'node:child_process'
/** Testable command boundary; native implementations never invoke a shell. */
export type PathOpenerRunner = (
command: string,
args: readonly string[],
signal: AbortSignal,
) => Promise<{ stdout: string; stderr: string }>
/** Injectable platform facts for deterministic adapter tests. */
export interface PathOpenerInternals {
platform?: NodeJS.Platform
run?: PathOpenerRunner
}
const runCommand: PathOpenerRunner = (command, args, signal) =>
new Promise((resolve, reject) => {
execFile(
command,
[...args],
{ encoding: 'utf8', signal, windowsHide: true },
(error, stdout, stderr) => {
if (error !== null) {
const failure = Object.assign(new Error(error.message, { cause: error }), {
code: error.code,
stdout,
stderr,
})
reject(failure)
return
}
resolve({ stdout, stderr })
},
)
})
/** PowerShell single-quoted literal (doubles embedded quotes). */
function powershellLiteral(path: string): string {
return `'${path.replace(/'/g, "''")}'`
}
/**
* Open a filesystem path with the operating system's default application.
* @param path - absolute or host-resolvable path (caller owns resolution).
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
*/
export async function openNativePath(
path: string,
signal: AbortSignal,
internals: PathOpenerInternals = {},
): Promise<void> {
const platform = internals.platform ?? process.platform
const run = internals.run ?? runCommand
if (platform === 'darwin') {
await run('open', [path], signal)
return
}
if (platform === 'win32') {
await run('powershell.exe', [
'-NoProfile',
'-Command',
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
], signal)
return
}
if (platform === 'linux') {
await run('xdg-open', [path], signal)
return
}
throw new Error(`native path opener is unsupported on ${platform}`)
}

View File

@@ -57,7 +57,10 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
pickDirectory?: (signal: AbortSignal) => Promise<string | null>,
extras: {
pickDirectory?: (signal: AbortSignal) => Promise<string | null>
openPath?: (path: string, signal: AbortSignal) => Promise<void>
} = {},
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -97,26 +100,29 @@ async function harness(
model: 'test-model',
cwd: workspaceRoot,
workspaceRoot,
...pickDirectory === undefined ? {} : { pickDirectory },
...extras.pickDirectory === undefined ? {} : { pickDirectory: extras.pickDirectory },
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
})
return { api, ctx, storageDomain, workspaceRoot }
}
describe('host.pickDirectory', () => {
it('returns a selected path or explicit cancellation from the injected native boundary', async () => {
const selected = await harness(undefined, async () => '/tmp/project')
const selected = await harness(undefined, { pickDirectory: async () => '/tmp/project' })
expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: '/tmp/project' } })
const cancelled = await harness(undefined, async () => null)
const cancelled = await harness(undefined, { pickDirectory: async () => null })
expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: null } })
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
const { api } = await harness(undefined, signal => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}))
const { api } = await harness(undefined, {
pickDirectory: signal => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
})
const abort = new AbortController()
const pending = api.host.pickDirectory(request({}), abort.signal)
abort.abort()
@@ -124,6 +130,30 @@ describe('host.pickDirectory', () => {
})
})
describe('host.openPath', () => {
it('opens through the injected native boundary', async () => {
const opened: string[] = []
const { api } = await harness(undefined, {
openPath: async (path) => { opened.push(path) },
})
expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
.toEqual({ ok: true, value: { opened: true } })
expect(opened).toEqual(['/tmp/a.txt'])
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
const { api } = await harness(undefined, {
openPath: (_path, signal) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
})
const abort = new AbortController()
const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal)
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
})
describe('workspace.create', () => {
it('serializes concurrent names and rejects the duplicate', async () => {
const { api, workspaceRoot } = await harness()

View File

@@ -50,6 +50,7 @@ function scriptedApi(overrides: {
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
pickDirectory: r => ok(r, { path: null }),
openPath: r => ok(r, { opened: true as const }),
...overrides.host,
},
workspace: {

View File

@@ -80,6 +80,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async pickDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }
},
async openPath(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
},
},
workspace: {
async list(request) {

View File

@@ -0,0 +1,62 @@
type ExecFileCallback = (
error: (Error & { code?: string | number }) | null,
stdout: string,
stderr: string,
) => void
type ExecFileMock = (
command: string,
args: readonly string[],
options: { encoding: string; signal: AbortSignal; windowsHide: boolean },
callback: ExecFileCallback,
) => void
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() }))
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { describe, expect, it, vi } from 'vitest'
import { openNativePath, type PathOpenerRunner } from '../src/native-path-opener.ts'
const signal = () => new AbortController().signal
describe('native path opener', () => {
it('opens with macOS open(1)', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/Users/test/file.txt', signal(), { platform: 'darwin', run })
expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal))
})
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
expect(run).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\o''reilly.txt'"],
expect.any(AbortSignal),
)
})
it('opens with Linux xdg-open', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal))
})
it('rejects unsupported platforms', async () => {
await expect(openNativePath('/x', signal(), { platform: 'freebsd' as NodeJS.Platform }))
.rejects.toThrow('unsupported on freebsd')
})
it('runs the default command adapter without a shell', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, '', '')
})
await openNativePath('/tmp/default.txt', signal(), { platform: 'darwin' })
const [command, args, options] = execFileMock.mock.calls[0]!
expect(command).toBe('open')
expect(args).toEqual(['/tmp/default.txt'])
expect(options.encoding).toBe('utf8')
expect(options.windowsHide).toBe(true)
expect(options.signal).toBeInstanceOf(AbortSignal)
})
})

View File

@@ -0,0 +1,9 @@
# 星光不负赶路人
梦想是远方的星光,而坚持是脚下的路。
有人说,梦想遥不可及,不过是年少轻狂的幻想。但翻看历史,哪一个伟大的成就不是始于一个看似不可能的梦想?爱迪生发明电灯前失败了上千次,屠呦呦历经数十载终获青蒿素。他们的共同点,不是天赋异禀,而是那份咬牙坚持的韧劲。
追梦的路上,难免有风雨和迷茫。但请相信,每一步都算数,每一次跌倒都是一次成长。当你想要放弃时,再坚持一下——星光终会照亮赶路人。
愿你不负韶华,不负梦想。