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

@@ -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