diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml new file mode 100644 index 0000000000..44869d6f05 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-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 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md new file mode 100644 index 0000000000..a2c9b52507 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md new file mode 100644 index 0000000000..efb4c39503 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -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 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index f53777fff8..ce5a98b2eb 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -5,7 +5,7 @@ // the code-variant parent row titled by the model-authored description, its // three always-visible nested sub-rows (bash through the sample registration, // read through GenericToolCard, the failing read wearing the error state), -// the expanded program body, details-panel resolution of a sub-callId, and +// the expanded program body, inert bash / file-link sub-row gestures, and // the trajectory/waterfall tabs' sub-call cells and timing lanes. import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -152,7 +152,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s `) }) -it('expands the code row into the program body and resolves a sub-row through the details panel', async () => { +it('expands the code row into the program body; sub-row clicks do not open details', async () => { boot() await openFixtureSession() @@ -171,26 +171,28 @@ it('expands the code row into the program body and resolves a sub-row through th } }) - // Sub-row click → details panel resolves the sub-callId with FULL output. + // Tool rows no longer drive the details panel: bash is inert, file paths + // are host-open links (fixture openPath is a no-op success). const nest = document.querySelector('[data-subcalls]') if (nest === null) throw new Error('sub-call nest missing') const bashRow = nest.querySelector('[data-sample="bash-global"]') if (bashRow === null) throw new Error('bash sample sub-row missing') + const fileLink = nest.querySelector('button') + if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row') + const frame = document.querySelector('[data-details-collapsed]') + if (frame === null) throw new Error('app frame missing') + expect(frame.getAttribute('data-details-collapsed')).toBe('true') fireEvent.click(bashRow) - const details = await screen.findByText('Input') - const panel = details.closest('[class*="root"]') - if (panel === null) throw new Error('details panel missing') + expect(frame.getAttribute('data-details-collapsed')).toBe('true') + fireEvent.click(fileLink) + expect(frame.getAttribute('data-details-collapsed')).toBe('true') expect({ - title: visibleText(within(panel as HTMLElement).getByText('bash')), - inputEchoesArgs: visibleText(panel).includes('ls notes'), - outputComplete: visibleText(panel).includes('demo.txt new-demo.txt') - || visibleText(panel).includes('demo.txt\nnew-demo.txt') - || (panel.textContent ?? '').includes('demo.txt\nnew-demo.txt'), + fileLink: visibleText(fileLink), + detailsCollapsed: frame.getAttribute('data-details-collapsed'), }).toMatchInlineSnapshot(` { - "inputEchoesArgs": true, - "outputComplete": true, - "title": "bash", + "detailsCollapsed": "true", + "fileLink": "notes/demo.txt", } `) }) diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 32c51a2a2a..a7f493e448 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -118,19 +118,14 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1) }, 60_000) - it.skipIf(MODE === 'record')('a sub-row click opens the details panel on the sub-call material', async () => { + it.skipIf(MODE === 'record')('a bash sub-row click leaves the details panel collapsed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details')) const nest = page.locator('[data-subcalls]').first() + const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() + expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() await nest.locator('[data-sample="bash-global"]').first().click() - // The details column opens (width > 0) and shows the sub-call's complete - // output — the full-content log contract, no truncation marker anywhere. - await page.waitForFunction(() => { - const frame = document.querySelector('[class*="frame"]') - if (frame === null) return false - return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0 - }, undefined, { timeout: 10_000 }) - await expect.poll(() => page.getByText('CODE_ROUND_OK', { exact: false }).count(), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(1) + // Tool rows no longer open details; the column stays width 0. + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() }) it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => { diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bbae7363df..a27a61f80f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -1,12 +1,11 @@ // Web e2e scenarios: navigation & panes — the view tabs (Trajectory / -// Waterfall), the details column, and sidebar search, all over ONE rich -// two-turn seeded fixture rendered purely from the log (the seeded-history -// pattern: zero model calls in replay, so every surface here is the client -// fold + host history RPC, not replay binding). The seed is recorded live -// under the standard discipline: turn 1 produces a bash call plus two -// parallel reads in one assistant message (tool-call density for the -// trajectory/waterfall lanes and a details-capable bash row), turn 2 a -// markdown-rich reply (a second turn so the waterfall has two lanes). +// Waterfall) and sidebar search, all over ONE rich two-turn seeded fixture +// rendered purely from the log (the seeded-history pattern: zero model calls +// in replay, so every surface here is the client fold + host history RPC, +// not replay binding). The seed is recorded live under the standard +// discipline: turn 1 produces a bash call plus two parallel reads in one +// assistant message (tool-call density for the trajectory/waterfall lanes), +// turn 2 a markdown-rich reply (a second turn so the waterfall has two lanes). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -25,7 +24,6 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', impor const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md') -const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' @@ -155,36 +153,27 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE) }, 60_000) - it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => { + it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) await page.getByRole('tab', { name: 'Chat' }).click() - // The bash toolview row routes its click to openDetails (read rows are - // expand-in-place instead — the seeded-history scenario owns that fold). const bashRow = page.locator('[data-sample="bash-global"]').first() await bashRow.waitFor({ timeout: 15_000 }) - // Open/closed is the frame's collapsed attribute: the column collapses to - // width 0 but its subtree deliberately never unmounts (hidden, not - // absent), so element presence/visibility cannot express the state. const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() await bashRow.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull() - // The open panel shows the selected call's name, arguments, and durable - // result (NAVIGATION_OK appears in the chat row too, hence >= 2 total). - await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) - // Golden of the open panel: tool name header, Input args, Output result. - const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd)) - .split(SEED_ID).join('{{seededId}}') - await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE) - await page.getByRole('button', { name: '关闭详情' }).click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + // Read summaries are host-open file links; they also must not open details. + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() }, 60_000) it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md', + 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', ]) }) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index cd91ace3d3..d30b3d7c39 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -132,21 +132,19 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) - it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => { + it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow')) - // Interaction over cold-resumed history: read rows are expand-in-place - // rows (rowExpands routes the click to toggleExpand, not openDetails), so - // the gesture under test is the inline fold over log-rebuilt content. - // Runs after the golden capture; still zero model calls. - const row = page.locator('[data-variant] [data-clickable][role="button"]').first() - await row.waitFor({ timeout: 10_000 }) - expect(await row.getAttribute('aria-expanded')).toBe('false') - await row.click() - await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') - // The expanded body renders the recorded tool result (a.txt's contents). - await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) - await row.click() - await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') + // Interaction over cold-resumed history: read summaries are host-open + // file links (not expand-in-place / not details). Runs after the golden + // capture; still zero model calls. + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() + expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + // Path label survives from the recorded args (a.txt). + await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 58db1beafd..47d6ea81f7 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -7,7 +7,7 @@ // // Selector convention: CSS Modules hash as [hash]_[local], so class-substring // selectors are unreliable — anchor on data-* attributes (data-variant / -// data-clickable / data-sample) or visible text. The one [class*=] use below +// data-sample) or visible text. The one [class*=] use below // (frame/handle) rides local names that survive hashing as suffixes; prefer // data-* for anything new. // @@ -448,7 +448,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '07-back-to-chat') }) - it('5 bash differential rendering: tool row click opens the details column', async () => { + it('5 bash differential rendering: tool row click leaves the details column collapsed', async () => { onTestFailed(() => saveFailureShot(page, 'w5-tool-details')) const input = page.locator('textarea').first() await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果') @@ -462,13 +462,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '08-bash-round') expect(await detailsTrack(page)).toBe(0) await toolRow.click() - // Selection channel: click writes selection + layout.openDetails. - await page.waitForFunction(() => { - const frame = document.querySelector('[class*="frame"]') - if (frame === null) return false - return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0 - }, undefined, { timeout: 10_000 }) - await screen(page, '09-details-open') + // Tool rows no longer drive layout.openDetails; the column stays closed. + expect(await detailsTrack(page)).toBe(0) + await screen(page, '09-details-closed') }, 150_000) it('6 sidebar drag widens the column and persists across reload', async () => { diff --git a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md deleted file mode 100644 index d69a95eb2d..0000000000 --- a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md +++ /dev/null @@ -1,5 +0,0 @@ -- text: bash -- button "关闭详情" -- text: Input -- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }" -- text: Output NAVIGATION_OK diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index bdd43de968..6aa932b576 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -790,6 +790,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 })) }), @@ -1049,6 +1050,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) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index bc73e0e054..33f6d0cc41 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -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') diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts index fe91bbae2d..0eaf09f149 100644 --- a/packages/client/connection/src/native-dialog-request.ts +++ b/packages/client/connection/src/native-dialog-request.ts @@ -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' diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 4f4ef25445..1dac997ae2 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -67,6 +67,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) + onOpenPath: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ opened: true as const })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -89,6 +91,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'] = { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 86af61ba0d..2c90cd8b7a 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -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) diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index fe345801c6..97f01d0bf1 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -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 { + 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. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index bc30b8633f..b18ae87d8e 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -85,6 +85,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) + onOpenPath: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ opened: true as const })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -107,6 +109,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> = () => Promise.resolve(ok({ items: [] })) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 5327066651..6768fddff7 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -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() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index c1a6828d5b..b685a6d9fe 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 56a445ccfa86e0b11cf5aefc37819a30746f0739 -README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070 +README.md: a04c20f225c731581accbe8c12c52a5e7597029a +README.zh.md: f9e6a635ea6090c87a66f029785af214025b9bda diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 56a445ccfa..a04c20f225 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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: `), 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 · ` or `Edit · ` 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: '', 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: '', 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 (`/ 已完成 · ` 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 `"/ tasks · 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a7c160ecdd..f9e6a635ea 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,9 +8,9 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。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 scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 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 · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 12b4bd1f79..68df3a6da0 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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' @@ -170,6 +171,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() }, } }, diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 2e32eea86c..5273c75357 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -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 { GenericCommandCard } from './GenericCommandCard.tsx' @@ -37,7 +36,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'] @@ -50,20 +49,18 @@ type UseConversation = SnapshotSelectorHook * 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, cwd }: { +const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: { renderSlot: RenderToolRow node: CodeSubCall - onOpenDetails: OpenDetails + openFile: OpenFile selected: boolean cwd: string | undefined }) { 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, cwd, - openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) }, - }), [node, toolName, seq, cwd, onOpenDetails]) + callId: node.callId, toolName, block: node, openFile, cwd, + }), [node, toolName, openFile, cwd]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -80,15 +77,13 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s * 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, cwd, + renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, }: { 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. */ @@ -99,9 +94,8 @@ const CallRow = memo(function CallRow({ cwd: string | undefined }) { const owner = useMemo(() => ({ - callId, toolName, block, cwd, - openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, - }), [callId, toolName, block, seq, cwd, onOpenDetails]) + callId, toolName, block, openFile, cwd, + }), [callId, toolName, block, openFile, cwd]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -115,7 +109,7 @@ const CallRow = memo(function CallRow({ key={node.callId} renderSlot={renderSlot} node={node} - onOpenDetails={onOpenDetails} + openFile={openFile} selected={node.callId === selectedCallId} cwd={cwd} /> @@ -127,10 +121,10 @@ const CallRow = memo(function CallRow({ }) /** Consecutive tool results as one step-run group (uniform 16px rhythm). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: { +const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: { 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). */ @@ -147,8 +141,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} @@ -229,7 +222,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, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) { +export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -330,7 +323,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio key={item.key} renderSlot={renderSlot} results={item.results} - onOpenDetails={openDetails} + openFile={openFile} selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} cwd={cwd} @@ -373,8 +366,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio 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} diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index e5b5fb541b..7bd4f22b06 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -25,8 +25,9 @@ const VARIANT_ICONS: Record = { others: , } -export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) { +export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) { const model = toolRowModel(toolName, block, cwd) + const singleFile = model.filePath !== undefined return ( ) } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 018529961f..7e2ed4db21 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -41,10 +41,9 @@ 90%, 100% { left: 100%; } } -/* Clickable rows keep only the cursor affordance — no hover fill. */ -.row[data-clickable] { +/* Expand-on-row (Think / code): pointer only — no row fill hover. */ +.row[data-expandable] { cursor: pointer; - border-radius: 6px; } .leading { @@ -143,6 +142,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; diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 5c5d059292..9395813910 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -2,9 +2,8 @@ // 16px leading slot (state dot / tool icon, chevron on hover or 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. -// TODO(ux): converge every chat-tab tool row on in-place expansion for its -// expandable content, retiring the details-panel handoff where feasible. +// 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' @@ -26,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 terminal state @@ -50,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 = () => { @@ -68,6 +77,10 @@ export function ToolRow({ event.preventDefault() toggleExpand() } + const openFile = (event: MouseEvent) => { + event.stopPropagation() + if (filePath !== undefined) onOpenFile?.(filePath) + } // Expandable rows preview the toggle on hover: the tool icon yields to a // down chevron (CSS swap on .row:hover); state dots still take precedence. const collapsedIcon = expandable @@ -85,11 +98,11 @@ export function ToolRow({
{expandable && !rowExpands ? ( @@ -110,7 +123,17 @@ export function ToolRow({ {!open && ( <> - {summary} + {fileLink ? ( + + ) : ( + {summary} + )} )}
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 690e925f03..d390ffc007 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -154,8 +154,11 @@ export interface ToolRowOwnerProps { block: ToolCallBlock /** Session workspace root; path summaries display relative to it. */ cwd?: string | undefined - /** 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 } /** @@ -310,6 +313,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 } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index c7db85b0aa..04a8c66d40 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -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 @@ -121,6 +127,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 = 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, 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) @@ -159,6 +194,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin variant, title: toolTitle ?? VARIANT_TITLES[variant], summary, + filePath: deriveFilePath(variant, argsRaw), body: deriveBody(variant, argsRaw), state, } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index a79faf30f1..2e5d601e62 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -7,8 +7,6 @@ align-items: center; height: 24px; min-width: 0; - cursor: pointer; - border-radius: 6px; } /* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index fbb1104eca..d972a35274 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -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} > {leadingFor(model.state)} {status !== null && {status}} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index 1a1b142b3a..8e59b36625 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -6,8 +6,6 @@ align-items: center; height: 24px; min-width: 0; - cursor: pointer; - border-radius: 6px; } .leading { diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index a47322b614..67732a3650 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -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