feat(chat): present tools in preparing, start, and result stages

This commit is contained in:
imccyu committed 2026-09-23 16:11:43 +08:00
1 parent d550f1c4af
commit d3086e6571
90 files changed
+547 -226

No files matched your search

+2 -2
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-chat/README.md
README.md: 1aa7d71b3f932a7a14ef6d23e9ace7eda444fc3b
README.zh.md: 2239234448e1e00fd395634de39a96c2a80a35f7
README.md: c9765db137f1b4fc5740cdf167fcf313d0f47b7a
README.zh.md: 9a11c301e742d309048e0990037780f7c576ea34
+1 -1
View File
@@ -85,7 +85,7 @@ Work-details modes control process-group display and reasoning previews; eligibl
Chat registers its process Group Definition through `uiConversation.groups`. React renders the mixed `node`/`group` root sequence through stable Group and Node seats; group headers subscribe to data separately from member arrays. Settled group titles remain independent of the live-detail preference; only running titles update when that preference changes. [Process-group business rules](src/client/conversation-nodes/README.md#process-grouping) define segmentation and activity summaries.
`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector.
`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector.
The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition.
+1 -1
View File
@@ -85,7 +85,7 @@ Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第
Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过稳定的 Group 与 Node 容器渲染混合 `node`/`group` 根序列,组头数据与成员数组分别订阅。已结束组的标题独立于实时详情偏好,只有运行中的标题在该偏好变化时更新。[过程分组业务规则](src/client/conversation-nodes/README.zh.md#process-grouping) 定义切分方式与活动摘要。
`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。
`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。
过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。
@@ -32,7 +32,7 @@ export function ApprovalCommand({ callId, useChat }: PropsRuntime<'conversation.
const command = useChat((snapshot) => {
for (const node of snapshot.nodes.values()) {
const root = node.kind === 'tool-call' ? (node as ChatNode<'tool-call'>).data.root : undefined
if (root !== undefined && root.callId === callId && !('kind' in root)) return commandOf(root)
if (root !== undefined && root.callId === callId && !('kind' in root) && root.phase === 'start') return commandOf(root)
}
return undefined
})
@@ -31,6 +31,7 @@ type ProcessTitleActivity = ProcessActivity | 'thinking'
interface LiveProcessTitle {
readonly activity: ProcessTitleActivity
readonly detail: string
readonly preparing: boolean
}
const PROCESS_ICONS: Record<ProcessTitleActivity, ReactNode> = {
@@ -49,7 +50,7 @@ const PROCESS_ICONS: Record<ProcessTitleActivity, ReactNode> = {
}
function sameLiveProcessTitle(left: LiveProcessTitle, right: LiveProcessTitle): boolean {
return left.activity === right.activity && left.detail === right.detail
return left.activity === right.activity && left.detail === right.detail && left.preparing === right.preparing
}
function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean): LiveProcessTitle {
@@ -73,7 +74,7 @@ function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean):
}
const timer = setTimeout(commit, remaining)
return () => { clearTimeout(timer) }
}, [active, desired.activity, desired.detail])
}, [active, desired.activity, desired.detail, desired.preparing])
return active ? displayed : desired
}
@@ -99,9 +100,12 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG
const live = useStableLiveProcessTitle({
activity: data?.summary.running ?? 'thinking',
detail: data?.summary.runningDetail ?? '',
preparing: data?.summary.preparing === true,
}, data !== undefined && !data.closed)
if (data === undefined) return null
const label = data.closed ? processTitle(data.summary, t) : t(`message.stepProcess.${live.activity}`)
const label = data.closed ? processTitle(data.summary, t)
: live.preparing ? t(`message.stepProcess.prepare.${live.activity === 'thinking' ? 'tools' : live.activity}`)
: t(`message.stepProcess.${live.activity}`)
const detail = detailed && !data.closed ? live.detail : ''
const title = detail === '' ? label : `${label}${t('message.turnProcess.separator')}${detail}`
const activity = data.closed ? data.summary.counts[0]?.kind ?? 'thinking' : live.activity
@@ -7,6 +7,8 @@ export interface ProcessActivitySummary {
readonly counts: readonly { readonly kind: ProcessActivity; readonly count: number }[]
readonly running: ProcessActivity | undefined
readonly runningDetail: string
/** Present only while the selected live activity has not reached tool/call. */
readonly preparing?: true
}
/** Presentation facts for one group between independent replies or input. */
@@ -8,7 +8,7 @@ import type { TurnProcessSpec } from './turn-process.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig,
AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem,
ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, TodoItem,
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
UserMessageNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -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-chat/src/client/conversation-nodes/README.md
README.md: 3a1660677e173461c353da1d40af3e8adae6d216
README.zh.md: 024ea0f5da55826651611a7059a647b0c6478e08
README.md: 5da692fefddcd3d3b01267c27f9f125472813d95
README.zh.md: ac10f4f0789533eb71d165efe1cba55c196d12f7
@@ -122,6 +122,8 @@ A closed group's header names the first three categories from its ranked summary
### Group-title rules
A preparing Tool node uses its category's preparation label and its wire name as detail. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation.
The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category.
| Category | Running label | Closed label |
@@ -210,7 +212,7 @@ Counts describe calls in this group, not successful operations, files changed, c
| Call evidence in this group | Counting rule |
|---|---|
| Running call, successful result, failed result, or projected interrupted result with its original call | Count one call in the category selected by its recorded tool name. Settlement and failure do not add or subtract a call. |
| Preparing call, running call, successful result, failed result, or projected interrupted result with its original call | Count one call in the category selected by its recorded tool name. Settlement and failure do not add or subtract a call. |
| Repeated `callId` | Count only the first occurrence, including when it appears both as a root and as a nested call. Later occurrences and their subtrees are skipped. |
| Parent and child with different `callId` values | Count both, in their own categories; visit the parent before its children and siblings in recorded order. |
| Repeated calls to the same tool with different `callId` values | Count each call, even with identical arguments or the same terminal/session target. |
@@ -256,6 +258,8 @@ Exact-name rules also mean that a recorded name such as `functions.read`, `mcp.r
### Live activity and detail
Preparing calls use their first named delta time and show only the tool name. Dispatched calls use their tool/call time and complete arguments.
Among running calls, the greatest `time` selects the live category and detail; equal times select the later visited call. With no running call, the category is absent and detail comes from the last nonempty reasoning paragraph of the latest running Assistant with nonempty reasoning, in member order. Reasoning detail removes `**` markers and does not require a newline-terminated first line; the individual reasoning-row preview has separate rules.
Live selection uses call start `time`, not the latest output/progress time. A call is a running candidate while its projected tool value has no result; pending approval or waiting for a result does not receive a separate category.
@@ -122,6 +122,8 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
### 组标题规则
准备中的 Tool 节点使用对应类别的准备文案,详情只有协议中的工具名。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。
以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。
| 类别 | 未结束组的活动文案 | 已结束组的摘要文案 |
@@ -210,7 +212,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 本组中的调用记录 | 计数规则 |
|---|---|
| 运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 |
| 准备中调用、运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 |
| 重复的 `callId` | 只计首次出现,包括同时作为根调用和嵌套调用出现的情况;后续重复记录及其子树不再处理。 |
| 父子调用的 `callId` 不同 | 父子分别按各自类别计数;父调用先于子调用,同级按记录顺序处理。 |
| 同一工具使用不同 `callId` 多次调用 | 每次都计数,即使参数相同,或操作的是同一个终端、Session。 |
@@ -256,6 +258,8 @@ Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成
### 实时活动与详情
准备中的调用使用首个具名 delta 的时间,详情只有工具名。已派发调用使用 tool/call 的时间和完整参数。
运行中的调用以最大 `time` 决定实时类别和详情;时间相同时取后遍历的调用。没有运行调用时,类别为空,详情取成员顺序中最近一个含有非空推理段落的运行中 Assistant 的最后一个非空段落。推理详情移除 `**` 标记,不要求首行以换行结束;单个推理行的预览另有规则。
实时选择依据调用开始的 `time`,不是最近一次输出或进度的时间。投影后的工具值尚无结果时就参与运行中候选;等待批准或等待结果不会产生另一个类别。
@@ -95,6 +95,7 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm
let running: ProcessActivity | undefined
let runningDetail = ''
let runningTime = -Infinity
let preparing: boolean | undefined
const visit = (tool: ToolCallBlock): void => {
if (seen.has(tool.callId)) return
seen.add(tool.callId)
@@ -103,7 +104,8 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm
const kind = activity(call.name)
if (isRunningTool(tool) && tool.time >= runningTime) {
running = kind
runningDetail = liveToolDetail(tool.name, tool.argsRaw)
preparing = tool.phase === 'preparing'
runningDetail = tool.phase === 'preparing' ? tool.name : liveToolDetail(tool.name, tool.argsRaw)
runningTime = tool.time
}
counts.set(kind, (counts.get(kind) ?? 0) + 1)
@@ -118,5 +120,6 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm
counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count),
running,
runningDetail,
...preparing ? { preparing: true } : {},
}
}
@@ -29,6 +29,7 @@ function reply(node: ChatNode): boolean {
function sameSummary(left: ProcessActivitySummary, right: ProcessActivitySummary): boolean {
return left.running === right.running && left.runningDetail === right.runningDetail
&& left.preparing === right.preparing
&& left.counts.length === right.counts.length && left.counts.every((value, index) =>
value.kind === right.counts[index]?.kind && value.count === right.counts[index].count)
}
@@ -1,11 +1,11 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall,
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, StartedToolCall,
ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type {} from '@deepseek-ai/dsh-tools/types'
import type { ToolChatData } from '../contract/chat-nodes.ts'
import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
declare module '../contract/chat-nodes.ts' {
@@ -37,8 +37,20 @@ function jsonArguments(value: unknown): string {
}
function rootCall(match: ConversationMatch): RunningToolCall {
const event = match.event
if (event.type === 'assistant/live-chunk') {
const chunk = event.data.chunk
if (chunk.type !== 'tool-call-delta' || !chunk.name) throw new Error('tool preparation requires a named call delta')
return {
phase: 'preparing',
callId: String(chunk.id), name: chunk.name,
turn: event.data.turn, step: event.data.step, time: event.time,
subCalls: [],
}
}
if (match.event.type !== 'tool/call') throw new Error('tool-call start requires tool/call')
return {
phase: 'start',
callId: String(match.event.data.callId),
name: match.event.data.name,
argsRaw: match.event.data.arguments,
@@ -49,7 +61,7 @@ function rootCall(match: ConversationMatch): RunningToolCall {
}
}
function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined {
function rootResult(match: ConversationMatch, previous?: StartedToolCall): ToolResultNode | undefined {
if (match.event.type !== 'tool/result') return undefined
const message = match.event.data.message
return {
@@ -77,8 +89,9 @@ interface DispatchData {
readonly content?: ToolResultNode['content']
}
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
function childCall(match: ConversationMatch, data: DispatchData): StartedToolCall {
return {
phase: 'start',
callId: data.subCallId,
parentCallId: data.parentCallId,
name: data.name,
@@ -174,6 +187,7 @@ function projectBlock(
visited = new Set<string>(),
depth = 1,
): ToolCallBlock {
if (!('kind' in block) && block.phase === 'preparing') return block
if (visited.has(block.callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] }
const nextVisited = new Set(visited)
nextVisited.add(block.callId)
@@ -229,11 +243,16 @@ function fallbackState(context: ConversationNodeContext<ToolState>): ToolState |
return state
}
/** Root Tool lifecycle and nested PTC dispatch Definition. */
/** Root Tool preparation, dispatch, result, and nested PTC calls. */
export const toolDefinition: ConversationNodeDefinition<ToolState> = {
kind: 'tool-call',
target: 'chat',
match: (event) => {
if (event.type === 'assistant/live-chunk') {
const chunk = event.data.chunk
return chunk.type === 'tool-call-delta' && chunk.name
? { id: String(chunk.id), role: 'start' } : null
}
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
return { id: String(event.data.message.source.callId), role: 'update' }
@@ -248,8 +267,10 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
},
start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }),
update: (context, match) => {
if (match.event.type === 'tool/call') return { ...context.state, root: rootCall(match) }
if (match.event.type === 'tool/result') {
const running = 'kind' in context.state.root ? undefined : context.state.root
const root = context.state.root
const running = !('kind' in root) && root.phase === 'start' ? root : undefined
const result = rootResult(match, running)
return result === undefined ? context.state : { ...context.state, root: result }
}
@@ -257,11 +278,18 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
},
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const projected = projectBlock(state.root, state, interruption(context))
if (state === undefined) {
const previous = context.current.get('chat') as ChatNode<'tool-call'> | null | undefined
return previous === undefined || previous === null ? null : { ...previous, visibility: 'hidden' }
}
const interruptedAt = interruption(context)
const projected = projectBlock(state.root, state, interruptedAt)
const anchor = context.start?.event.seq
?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0)
return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData)
const preparing = !('kind' in projected) && projected.phase === 'preparing'
return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData, {
visibility: preparing && interruptedAt !== undefined ? 'hidden' : 'visible',
})
},
}
+1 -1
View File
@@ -18,7 +18,7 @@ export type {
AssistantTiming, ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore,
ChatSnapshot, ChatTurnNavigationIndex, ChatTurnProcessPresentation,
CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
LegacyConversationSlice, ModelRetryNode, PartialAssistant, RunningToolCall,
LegacyConversationSlice, ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall,
SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
TurnNavigationItem, UnknownSurfaceNode, UserMessageNode,
} from './contract/snapshot.ts'
@@ -17,6 +17,17 @@ export const zh = {
'message.stepProcess.plan': '正在更新计划',
'message.stepProcess.questions': '等待你的操作',
'message.stepProcess.tools': '正在调用工具',
'message.stepProcess.prepare.read': '准备读取文件',
'message.stepProcess.prepare.search': '准备搜索代码',
'message.stepProcess.prepare.edit': '准备编辑文件',
'message.stepProcess.prepare.commands': '准备运行命令',
'message.stepProcess.prepare.code': '准备运行代码',
'message.stepProcess.prepare.webSearch': '准备搜索网页',
'message.stepProcess.prepare.webFetch': '准备访问网页',
'message.stepProcess.prepare.subagents': '准备协调子任务',
'message.stepProcess.prepare.plan': '准备更新计划',
'message.stepProcess.prepare.questions': '准备提问',
'message.stepProcess.prepare.tools': '准备调用工具',
'message.stepProcess.done.thinking': '已完成分析',
'message.stepProcess.done.read': '已读取文件',
'message.stepProcess.done.search': '已搜索代码',
@@ -175,6 +186,17 @@ export const en = {
'message.stepProcess.plan': 'Updating the plan',
'message.stepProcess.questions': 'Waiting for your action',
'message.stepProcess.tools': 'Calling tools',
'message.stepProcess.prepare.read': 'Preparing to read files',
'message.stepProcess.prepare.search': 'Preparing to search code',
'message.stepProcess.prepare.edit': 'Preparing to edit files',
'message.stepProcess.prepare.commands': 'Preparing to run commands',
'message.stepProcess.prepare.code': 'Preparing to run code',
'message.stepProcess.prepare.webSearch': 'Preparing to search the web',
'message.stepProcess.prepare.webFetch': 'Preparing to visit web pages',
'message.stepProcess.prepare.subagents': 'Preparing to coordinate subagents',
'message.stepProcess.prepare.plan': 'Preparing to update the plan',
'message.stepProcess.prepare.questions': 'Preparing questions',
'message.stepProcess.prepare.tools': 'Preparing tool calls',
'message.stepProcess.done.thinking': 'Analysis completed',
'message.stepProcess.done.read': 'Read files',
'message.stepProcess.done.search': 'Searched code',
@@ -58,6 +58,7 @@ export class ToolCallTree {
if (event.type === 'tool/ptc-dispatch-start') {
const data = event.data
const running: RunningToolCall = {
phase: 'start',
callId: data.subCallId,
parentCallId: data.parentCallId,
name: data.name,
@@ -30,18 +30,19 @@ describe('ApprovalCommand', () => {
it('renders the running correlated Tool command', () => {
render(<ApprovalCommand {...props([
{ kind: 'assistant-step', data: {} },
{ kind: 'tool-call', data: { root: { callId: 'other', argsRaw: '{"command":"wrong"}' } } },
{ kind: 'tool-call', data: { root: { callId: 'call-1', argsRaw: '{"command":"pnpm test"}' } } },
{ kind: 'tool-call', data: { root: { phase: 'start', callId: 'other', argsRaw: '{"command":"wrong"}' } } },
{ kind: 'tool-call', data: { root: { phase: 'start', callId: 'call-1', argsRaw: '{"command":"pnpm test"}' } } },
] as never)} />)
expect(screen.getByText('pnpm test')).toBeTruthy()
})
it('omits absent, uncorrelated, and settled Tool calls', () => {
it('omits absent, uncorrelated, preparing, and settled Tool calls', () => {
const { container, rerender } = render(<ApprovalCommand {...props([
{ kind: 'assistant-step', data: {} },
{ kind: 'tool-call', data: { root: undefined } },
{ kind: 'tool-call', data: { root: { callId: 'other', argsRaw: '{}' } } },
{ kind: 'tool-call', data: { root: { phase: 'start', callId: 'other', argsRaw: '{}' } } },
{ kind: 'tool-call', data: { root: { phase: 'preparing', callId: 'call-1', name: 'bash' } } },
{
kind: 'tool-call',
data: { root: { kind: 'tool-result', callId: 'call-1', argsRaw: '{"command":"ignored"}' } },
@@ -50,7 +51,7 @@ describe('ApprovalCommand', () => {
expect(container.textContent).toBe('')
rerender(<ApprovalCommand {...props([
{ kind: 'tool-call', data: { root: { callId: 'call-1', argsRaw: '{}' } } },
{ kind: 'tool-call', data: { root: { phase: 'start', callId: 'call-1', argsRaw: '{}' } } },
] as never)} />)
expect(container.textContent).toBe('')
})
@@ -9,7 +9,7 @@ import { useEffect } from 'react'
import type {
AssistantMessageNode, ChatNode, ChatNodeHookContext, ChatNodeOwnerProps, ChatSnapshot,
ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
LegacyConversationSlice, ModelRetryNode, RunningToolCall, SteeringMessageNode,
LegacyConversationSlice, ModelRetryNode, StartedToolCall, SteeringMessageNode,
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData,
TranscriptViewMode, UserMessageNode,
} from '@deepseek-ai/dsh-client-ui-chat/client'
@@ -206,8 +206,8 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
callTime: seq * 1_000 - 500,
content: [], isError: false, subCalls: [],
})
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [],
const runningCall = (callId: string, name = 'bash'): StartedToolCall => ({
phase: 'start' as const, callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [],
})
const command = (over: Partial<CommandNode> = {}): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
@@ -1002,7 +1002,8 @@ describe('built-in conversation node Definitions', () => {
}, { surfaceOp: 'append' }),
])
const toolOnlySnapshot = snapshot(toolOnlyValue)
expect(toolOnlySnapshot.order.map(key => toolOnlySnapshot.nodes.get(key)?.kind)).toEqual(['turn-process'])
expect(toolOnlySnapshot.order.map(key => toolOnlySnapshot.nodes.get(key)?.kind)).toEqual(['turn-process', 'tool-call'])
expect(node(toolOnlySnapshot, 'tool-call')?.data).toMatchObject({ root: { phase: 'preparing' } })
expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden')
expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{
kind: 'assistant',
@@ -35,7 +35,7 @@ function tool(key: string, seq: number, name = 'bash', owner = turn): ChatNode<'
return {
key, id: key, kind: 'tool-call', target: 'chat', anchorSeq: seq,
location: { kind: 'turn', turn: owner }, visibility: 'visible',
data: { root: { callId: key, name, argsRaw: '{"command":"pwd"}', turn: owner.turn, step: 1, time: seq, subCalls: [] } },
data: { root: { phase: 'start' as const, callId: key, name, argsRaw: '{"command":"pwd"}', turn: owner.turn, step: 1, time: seq, subCalls: [] } },
}
}
@@ -1,6 +1,6 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import type { RunningToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts'
import type { StartedToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts'
import {
MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree,
} from '../src/client/model/tool-call-tree.ts'
@@ -19,8 +19,8 @@ const settle = (seq: number, parentCallId: string, subCallId: string): SessionEv
isError: false, content: [],
})
const root = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
const root = (callId: string): StartedToolCall => ({
phase: 'start' as const, callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
time: 1_700_000_000_000, subCalls: [],
})
@@ -257,22 +257,35 @@ export type ConversationNode =
| CompactionSummaryNode
| UnknownSurfaceNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
/** Identity and placement shared by tool preparation and dispatch. */
interface ToolCallHead {
callId: string
/** Parent Tool call for a PTC dispatch start; absent on a root Session call. */
parentCallId?: string
name: string
argsRaw: string
turn: number
step: number
/** Unix epoch ms when the tool/call event was logged. */
/** Unix epoch ms when this stage began. */
time: number
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
}
/** One running or settled call, recursively owning its child calls. */
/** A named model call whose arguments are not yet available to tool views. */
export interface PreparingToolCall extends ToolCallHead {
readonly phase: 'preparing'
}
/** A dispatched tool call with complete arguments and no result yet. */
export interface StartedToolCall extends ToolCallHead {
readonly phase: 'start'
readonly argsRaw: string
}
/** A tool still preparing or awaiting its result. */
export type RunningToolCall = PreparingToolCall | StartedToolCall
/** One preparing, dispatched, or settled call, recursively owning its child calls. */
export type ToolCallBlock = RunningToolCall | ToolResultNode
/** In-progress assistant output (chunk accumulator product). */
@@ -30,7 +30,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig,
AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem,
ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, TodoItem,
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
UserMessageNode,
} from './contract/records.ts'
@@ -282,6 +282,7 @@ export const zh = {
'bash.failed': '失败',
'bash.stopped': '已停止',
'row.running': '运行中',
'row.preparing': '正在准备调用',
'row.failed': '失败',
'row.stopped': '已停止',
'row.input': '输入',
@@ -634,6 +635,7 @@ export const en = {
'bash.failed': 'Failed',
'bash.stopped': 'Stopped',
'row.running': 'Running',
'row.preparing': 'Preparing tool call',
'row.failed': 'Failed',
'row.stopped': 'Stopped',
'row.input': 'IN',
@@ -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-deliverables/README.md
README.md: c52db38df0c800f52de4eebb3a9692eabef6c9df
README.zh.md: 785c2215746e36ead16fe08b1a21b04a58ef418c
README.md: 8a0c51a8c88418862438342fc2a0faf41055dd00
README.zh.md: 3c01c405d390e6fcd06665d9a53face55f5c39dc
@@ -30,6 +30,8 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](.
<a id="explicit-deliveries"></a>
### Explicit deliveries
The `present` tool's preparing stage is one non-expandable document-icon row. It does not show filenames, create delivery cards, or enable file actions before the recorded call supplies arguments.
The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply.
The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop.
@@ -30,6 +30,8 @@ kind: "package-reference"
<a id="explicit-deliveries"></a>
### 显式交付
`present` 工具的准备阶段是带文档图标的不可展开单行。记录的调用提供参数前,该行不显示文件名、不创建交付卡片,也不启用文件操作。
Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。
`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。
@@ -25,7 +25,19 @@ function fileNames(raw: string): string {
* @param props - tool call and localized status copy.
* @returns a status row with a result disclosure.
*/
export function PresentRow({ block, inspect, t }: PresentRowProps) {
export function PresentRow(props: PresentRowProps) {
return props.phase === 'preparing' ? <PreparingPresentRow {...props} /> : <StartedPresentRow {...props} />
}
function PreparingPresentRow({ t, useDisclosure }: Extract<PresentRowProps, { phase: 'preparing' }>) {
const { toggle } = useDisclosure()
return <div data-tool="present" data-state="preparing" aria-label={t('row.preparing')}>
<DisclosureRow title={t('row.title')} icon={<IconDeliverDocRegular size={14} />}
open={false} expandable={false} onToggle={toggle} running />
</div>
}
function StartedPresentRow({ block, inspect, t }: Exclude<PresentRowProps, { phase: 'preparing' }>) {
const settled = 'kind' in block
const state = !settled ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok'
const args = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
@@ -28,6 +28,7 @@ export const zh = {
'presented.file': '文件',
'row.title': '交付文件',
'row.running': '正在交付',
'row.preparing': '准备交付',
'row.ok': '已交付',
'row.error': '交付失败',
'row.stopped': '已中断',
@@ -90,6 +91,7 @@ export const en: Record<DeliverablesKey, string> = {
'presented.file': 'File',
'row.title': 'Present files',
'row.running': 'Delivering',
'row.preparing': 'Preparing deliverables',
'row.ok': 'Delivered',
'row.error': 'Delivery failed',
'row.stopped': 'Interrupted',
@@ -2,17 +2,18 @@
/** Present UI derives statuses and details from durable tool records. */
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { PresentRow } from '../src/client/PresentRow.tsx'
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
type Props = Parameters<typeof PresentRow>[0]
const running: RunningToolCall = { callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] }
const running: StartedToolCall = { phase: 'start' as const, callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] }
const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'p', call: { name: 'present', argsRaw: running.argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Presented report.txt (4 bytes)' }], isError: false, subCalls: [] }
function props(block: Props['block'], inspect?: () => void): Props {
return { block, callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props
return { ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props
}
it('discloses the saved result and offers call inspection', () => {
@@ -32,6 +33,15 @@ it('discloses the saved result and offers call inspection', () => {
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('shows a non-expandable preparation before delivery arguments exist', () => {
const view = render(<PresentRow {...props({
phase: 'preparing', callId: 'p', name: 'present', turn: 1, step: 1, time: 1, subCalls: [],
})} useDisclosure={useDisclosure} />)
expect(view.container.querySelector('[data-state="preparing"] svg')).not.toBeNull()
expect(view.queryByRole('button')).toBeNull()
expect(view.container.querySelector('pre')).toBeNull()
})
it.each([
[running, 'running', 'Delivering'],
[{ ...settled, isError: true }, 'error', 'Delivery failed'],
+2 -2
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-skill/README.md
README.md: fdd8cb7fddb54cf73d897bb278086428d0e41214
README.zh.md: 2090711d5f91bf1efba2f8e19d8d361f9ffb99d9
README.md: 0234ce8fb6c2737ea242ac5590e471c3e4c91834
README.zh.md: 5b039ec1c5bea818e6396f4896c05b8175beb08a
+2
View File
@@ -33,6 +33,8 @@ Ordinary-session candidates come from the `skills/list` Remote; the host serves
### The skill tool row
The `preparing` stage shows only the skill icon and title, with no requested skill name or expansion. The `start` and `result` stages use the recorded arguments and output described below; preparation does not consult the installed catalog.
A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. Every lifecycle state retains the ordinary skill glyph; running calls carry the transcript shimmer, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change.
Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview.
+2
View File
@@ -33,6 +33,8 @@ kind: "package-reference"
### skill 工具行
`preparing` 阶段只显示技能图标与标题,不显示请求加载的技能名称,也不能展开。`start` 和 `result` 阶段使用下述记录参数与输出;准备阶段不查询已安装目录。
收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。所有生命周期状态都保留普通 skill 图标;运行中的调用带有 transcript(文本记录)的扫光效果,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。
鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。
@@ -18,7 +18,8 @@
cursor: pointer;
}
.card[data-state='running'] .row::after {
.card[data-state='running'] .row::after,
.card[data-state='preparing'] .row::after {
content: '';
position: absolute;
inset: 0 auto 0 0;
@@ -2,7 +2,7 @@ import { useState, type KeyboardEvent, type ReactNode } from 'react'
import {
IconChevronDownOutlineRegular, IconInspectOutlineRegular, IconSkillOutlineRegular,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import type { StartedToolCallViewProps, ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import css from './SkillRow.module.css'
@@ -42,7 +42,7 @@ function skillName(argsRaw: string, callId: string): string {
/** Flatten durable result blocks under the generic Tool-row text contract.
* Keep aligned with ui-tool's models/tool-call-model.ts `resultText`. */
function resultText(block: ToolCallViewProps['block']): string | null {
function resultText(block: StartedToolCallViewProps['block']): string | null {
if (!('kind' in block)) return null
const parts: string[] = []
for (const item of block.content) {
@@ -55,7 +55,7 @@ function resultText(block: ToolCallViewProps['block']): string | null {
}
/** Derive display state without consulting the live skill catalog. */
function skillRowModel(block: ToolCallViewProps['block']): SkillRowModel {
function skillRowModel(block: StartedToolCallViewProps['block']): SkillRowModel {
const settled = 'kind' in block
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
const state: SkillRowState = !settled
@@ -99,7 +99,18 @@ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null
* @param props - keyed toolview payload plus the skill locale seat.
* @returns the dedicated skill row.
*/
export function SkillRow({ block, inspect, t }: SkillRowProps) {
export function SkillRow(props: SkillRowProps) {
if (props.phase === 'preparing') return <div className={css.card} data-tool="skill" data-state="preparing">
<div className={css.row}>
<span className={css.leading}><IconSkillOutlineRegular size={14} /></span>
<span className={css.visuallyHidden}>{props.t('row.preparing')}</span>
<span className={css.title}>{props.t('row.title')}</span>
</div>
</div>
return <StartedSkillRow {...props} />
}
function StartedSkillRow({ block, inspect, t }: Exclude<SkillRowProps, { phase: 'preparing' }>) {
const model = skillRowModel(block)
const [expanded, setExpanded] = useState(false)
const expandable = model.output !== null
@@ -7,6 +7,7 @@ export const NS = 'skill'
export const zh = {
'row.title': '加载技能',
'row.running': '正在加载 skill',
'row.preparing': '准备加载技能',
'row.failed': 'skill 加载失败',
'row.stopped': 'skill 加载已中止',
'row.instructions': '说明',
@@ -21,6 +22,7 @@ export type SkillKey = keyof typeof zh
export const en = {
'row.title': 'Skill',
'row.running': 'Loading skill',
'row.preparing': 'Preparing to load a skill',
'row.failed': 'Skill load failed',
'row.stopped': 'Skill load stopped',
'row.instructions': 'Instructions',
@@ -134,6 +134,7 @@ describe('apply', () => {
zh: {
'row.title': '加载技能',
'row.running': '正在加载 skill',
'row.preparing': '准备加载技能',
'row.failed': 'skill 加载失败',
'row.stopped': 'skill 加载已中止',
'row.instructions': '说明',
@@ -143,6 +144,7 @@ describe('apply', () => {
en: {
'row.title': 'Skill',
'row.running': 'Loading skill',
'row.preparing': 'Preparing to load a skill',
'row.failed': 'Skill load failed',
'row.stopped': 'Skill load stopped',
'row.instructions': 'Instructions',
@@ -2,7 +2,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { SkillRow } from '../src/client/SkillRow.tsx'
@@ -29,9 +29,9 @@ function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
}
}
function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall {
function running(argsRaw = '{"name":"dsh-manage-issues"}'): StartedToolCall {
return {
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [],
phase: 'start' as const, callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [],
}
}
@@ -39,7 +39,7 @@ function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowPro
return {
callId: block.callId,
toolName: 'skill',
block,
...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }),
openFile: vi.fn(),
inspect,
@@ -48,6 +48,16 @@ function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowPro
}
describe('SkillRow', () => {
it('shows preparation without arguments, instructions, or disclosure', () => {
const view = render(<SkillRow {...props({
phase: 'preparing', callId: 'call-skill', name: 'skill', turn: 1, step: 1, time: 1, subCalls: [],
})} />)
expect(view.getByText('准备加载技能')).toBeTruthy()
expect(view.container.querySelector('svg')).not.toBeNull()
expect(view.queryByRole('button')).toBeNull()
expect(view.container.querySelector('pre')).toBeNull()
})
it('renders a compact Bash-shaped summary and discloses the exact instructions', () => {
const inspect = vi.fn()
const view = render(<SkillRow {...props(settled(), inspect)} />)
+2 -2
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-tool/README.md
README.md: 622dbd89c93f8a21bece72f2267613a4fa81b4d6
README.zh.md: c3fec14cb7a5d2e2448743204b59dcac73380849
README.md: 8f0dfd0bc0222bb6ea428deb0dd6769f7347e41c
README.zh.md: bd4681022f1215a3e0dcda48b73c911b5bc6a283
+6 -2
View File
@@ -29,6 +29,8 @@ Tool calls appear in the conversation as cards: a root call tree with its nested
Shared Tool rows and Bash rows retain error and warning colors for failed and stopped summaries, including on hover. Hover darkens only summaries without those states.
Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. Preparation provides no argument body, file link, result, or interaction requiring parameters. `tool/call` enables the existing call presentation; completing an argument block alone does not start execution.
### Registering a business tool view
An owning business package registers its wire Tool name into `tool.call.toolview`:
@@ -45,6 +47,8 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo
### Built-in views
Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages and retain their existing icon and title. The common row model returns empty presentation fields during preparation, and the shared argument parser returns no call without parsing JSON. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`.
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
-----
@@ -59,7 +63,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
### Rendering contract
`ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, the session `cwd`, and the owner's callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map. Each root and child wrapper preserves the `data-chat-anchor-key="call:<id>"` and `data-chat-call-id` DOM contract used for paging and selection.
`ToolCallTree` receives one Tool node, Session `cwd`, and navigation callbacks. It derives explicit phase props from the root and dispatches each tool by name through `tool.call.toolview`. Dispatched roots retain their recursive `subCalls`; preparation has no children. Each root and child wrapper preserves the `data-chat-anchor-key="call:<id>"` and `data-chat-call-id` DOM contract used for paging and selection. The Tool node keeps the same callId across all three stages.
Tool owner props forward Chat's stable `useDisclosure` Hook through root and nested calls. Rows invoke it where they own their expanded bodies; intermediate renderers do not subscribe. Each invocation has independent open state that resets when the enclosing Turn collapses, without replacing React identity. Presentation-mode switches preserve it.
@@ -98,7 +102,7 @@ These pages cover the conversation host, the view slots, and the card models.
<a id="model-experience"></a>
## Model Experience
None, as the package is a browser-side tool presentation layer that renders logged calls without changing model context.
None, as the package renders streamed tool identities and logged calls without changing model context.
#### KV Cache effect
+6 -2
View File
@@ -29,6 +29,8 @@ kind: "package-reference"
共享工具行和 Bash 行的失败、停止摘要在悬停时仍保留错误色和警告色;只有不处于这两种状态的摘要会在悬停时加深。
派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。准备阶段不提供参数正文、文件链接、结果或依赖参数的交互。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。
### 注册业务工具视图
拥有该视图的业务包将其 wire 工具名称注册进 `tool.call.toolview`:
@@ -45,6 +47,8 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
### 内置视图
每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`,沿用既有图标和标题。公共行模型在准备阶段返回空展示字段,共享参数解析入口直接返回无调用,不解析 JSON。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
-----
@@ -59,7 +63,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
### 渲染约定
`ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、会话 `cwd`,以及属主用于打开文件和检查调用的回调。它递归遍历标准调用块,让 root 与任意深度的 child 经过同一条原子分发路径,不订阅独立的 parent-to-children map。每个 root 和 child 包装层都保留 `data-chat-anchor-key="call:<id>"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。
`ToolCallTree` 接收一个 Tool 节点、会话 `cwd` 和导航回调。它从根调用数据生成显式阶段 props,并通过 `tool.call.toolview` 按工具名分发。已派发的根调用保留递归 `subCalls`,准备阶段没有子调用。每个根调用和子调用包装层都保留 `data-chat-anchor-key="call:<id>"` 与 `data-chat-call-id` DOM 约定,供分页和选择使用。Tool 节点在三个阶段保持同一个 callId。
Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调用及嵌套调用。工具行在拥有展开正文的位置调用它,中间 renderer 不订阅。每次调用拥有独立展开状态,外层轮次收起时重置该状态,不替换 React 身份;展示模式切换保留该状态。
@@ -98,7 +102,7 @@ terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice
<a id="model-experience"></a>
## 模型体验
无。该包是浏览器端工具展示层,只渲染已记录的工具调用,不改变模型上下文。
无。该包渲染流式工具身份与已记录调用,不改变模型上下文。
#### KV Cache 影响
@@ -3,7 +3,10 @@ import type {
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
import type { OpenFileOptions, ToolCallBlock, UseDisclosure } from '@deepseek-ai/dsh-client-ui-chat/client'
import type {
OpenFileOptions, PreparingToolCall, StartedToolCall,
ToolResultNode, UseDisclosure,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -16,7 +19,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*
* Registering an occupied key replaces its view; unclaimed keys use the
* generic row. The owner supplies the call identity and frozen running
* or settled node through ToolCallOwnerProps.
* or settled node through explicit phase props; preparing inputs have no arguments.
*/
'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
/**
@@ -47,15 +50,13 @@ export interface ToolImagesOwnerProps {
}
/** Standard owner currency supplied to every atomic Tool view. */
export interface ToolCallOwnerProps {
interface ToolCallCommonProps {
/** Stable injected Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */
useDisclosure: UseDisclosure
/** Tool call identity, stable across running and settled forms. */
callId: string
/** Wire Tool name and keyed dispatch value. */
toolName: string
/** Frozen running call or settled result node. */
block: ToolCallBlock
/** Session workspace root for relative summaries. */
cwd?: string | undefined
/** Host account home; POSIX home-rooted summaries display as `~`. */
@@ -77,9 +78,21 @@ export interface ToolCallOwnerProps {
inspect?: (() => void) | undefined
}
/** Stage-specific tool data; only start/result expose the dispatched call material. */
export type ToolCallPhaseProps =
| { readonly phase: 'preparing'; readonly block: PreparingToolCall }
| { readonly phase: 'start'; readonly block: StartedToolCall }
| { readonly phase: 'result'; readonly block: ToolResultNode }
/** Common owner callbacks and the data admitted at the current tool stage. */
export type ToolCallOwnerProps = ToolCallCommonProps & ToolCallPhaseProps
/** Full props of a registered atomic Tool view. */
export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
/** Existing argument/result business components exclude the preparation stage. */
export type StartedToolCallViewProps = Exclude<ToolCallViewProps, { readonly phase: 'preparing' }>
/** Injected Host description for POSIX home-path display. */
export type ToolHostInfoInjected = {
hooks: {
@@ -93,7 +106,7 @@ export type ToolHostInfoInjected = {
}
}
/** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */
/** Full props of the Tool call-tree renderer registered as a tool-call Chat Node. */
export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'>
& PropsRenderSlots<'tool.call.toolview'>
& PropsLocale<'conversation'>
+2 -1
View File
@@ -1,5 +1,6 @@
/** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */
export { apply, inject } from './apply.ts'
export type {
ToolCallOwnerProps, ToolCallViewProps, ToolHostInfoInjected, ToolTreeProps,
StartedToolCallViewProps, ToolCallOwnerProps, ToolCallPhaseProps, ToolCallViewProps,
ToolHostInfoInjected, ToolTreeProps,
} from './contract/slots.ts'
@@ -1,40 +1,45 @@
/** Root/subcall Tool composition with one keyed atomic dispatch path. */
import { memo, useMemo, type ReactNode } from 'react'
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts'
import type { ToolCallOwnerProps, ToolCallPhaseProps, ToolTreeProps } from '../contract/slots.ts'
import { toolRowModel } from './models/tool-call-model.ts'
import { GenericToolCard } from './toolviews/GenericToolCard.tsx'
import css from './ToolCallTree.module.css'
/** Resolve a Tool call's wire name from either lifecycle form. */
function callName(node: ToolCallBlock): string {
return 'kind' in node ? node.call?.name ?? '' : node.name
function toolCallPhase(block: ToolCallBlock): ToolCallPhaseProps {
if ('kind' in block) return { phase: 'result', block }
return block.phase === 'preparing' ? { phase: 'preparing', block } : { phase: 'start', block }
}
/** Resolve a Tool call's wire name from its current stage. */
function callName(call: ToolCallPhaseProps): string {
return call.phase === 'result' ? call.block.call?.name ?? '' : call.block.name
}
/** One atomic call dispatched through the Tool-owned keyed slot. */
const ToolCall = memo(function ToolCall({
renderSlot, callId, toolName, block, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children,
renderSlot, callId, toolName, call, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children,
}: Pick<ToolTreeProps, 'renderSlot' | 'openFile' | 'cwd' | 'inspectCall' | 'loadImage' | 'useDisclosure' | 't'> & {
callId: string
toolName: string
block: ToolCallBlock
call: ToolCallPhaseProps
home?: string | undefined
children?: ReactNode
}) {
const owner: ToolCallOwnerProps = useMemo(() => ({
callId,
toolName,
block,
...call,
openFile,
cwd,
home,
loadImage,
useDisclosure,
inspect: inspectCall === undefined ? undefined : () => { inspectCall(callId) },
}), [callId, toolName, block, openFile, cwd, home, loadImage, inspectCall, useDisclosure])
}), [callId, toolName, call, openFile, cwd, home, loadImage, inspectCall, useDisclosure])
const autoReviewDenied = useMemo(
() => toolRowModel(toolName, block).autoReviewDenial !== null,
[toolName, block],
() => call.phase === 'result' && toolRowModel(toolName, call.block).autoReviewDenial !== null,
[toolName, call],
)
return (
<div
@@ -54,17 +59,17 @@ const ToolCall = memo(function ToolCall({
})
const ToolCallBranch = memo(function ToolCallBranch({
renderSlot, block, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t,
renderSlot, call, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t,
}: Pick<ToolTreeProps, 'renderSlot' | 'cwd' | 'openFile' | 'inspectCall' | 'loadImage' | 'useDisclosure' | 't'> & {
block: ToolCallBlock
call: ToolCallPhaseProps
home?: string | undefined
}) {
return (
<ToolCall
renderSlot={renderSlot}
callId={block.callId}
toolName={callName(block)}
block={block}
callId={call.block.callId}
toolName={callName(call)}
call={call}
openFile={openFile}
cwd={cwd}
home={home}
@@ -73,13 +78,13 @@ const ToolCallBranch = memo(function ToolCallBranch({
loadImage={loadImage}
t={t}
>
{block.subCalls.length > 0 ? (
{call.phase !== 'preparing' && call.block.subCalls.length > 0 ? (
<div className={css.subCalls} data-subcalls>
{block.subCalls.map(child => (
{call.block.subCalls.map(child => (
<ToolCallBranch
key={child.callId}
renderSlot={renderSlot}
block={child}
call={toolCallPhase(child)}
cwd={cwd}
home={home}
openFile={openFile}
@@ -105,11 +110,11 @@ export function ToolCallTree({
renderSlot, node, cwd, openFile, inspectCall, loadImage, useDisclosure, useHostInfo, t,
}: ToolTreeProps) {
const home = useHostInfo(info => info.home)
const block = node.data.root
const call = useMemo(() => toolCallPhase(node.data.root), [node.data.root])
return (
<ToolCallBranch
renderSlot={renderSlot}
block={block}
call={call}
cwd={cwd}
home={home}
openFile={openFile}
@@ -0,0 +1,22 @@
/** Tool-owned non-expandable chrome without dispatched argument material. */
import type { ReactNode } from 'react'
import type { ToolCallViewProps } from '../../contract/slots.ts'
import { classifyTool } from '../models/tool-call-model.ts'
import { ToolRow, type ToolRowProps } from './ToolRow.tsx'
/** Inputs contain a tool-owned icon/title and no expandable body. */
type PreparingToolRowProps = Pick<ToolCallViewProps, 'toolName' | 'useDisclosure'> & {
readonly icon: ReactNode
readonly title: string
readonly t: ToolRowProps['t']
}
/**
* Render an argument-free, non-expandable tool prefix.
* @param props - tool prefix and locale.
* @returns the preparation row.
*/
export function PreparingToolRow({ toolName, useDisclosure, icon, title, t }: PreparingToolRowProps) {
return <ToolRow useDisclosure={useDisclosure} t={t} variant={classifyTool(toolName)} toolName={toolName}
icon={icon} title={title} summary="" state="preparing" />
}
@@ -96,6 +96,7 @@ export interface ToolRowProps {
/** Visually hidden run-state label for color-only running and settlement cues. */
function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null {
switch (state) {
case 'preparing': return t('row.preparing')
case 'running': return t('row.running')
case 'error': return t('row.failed')
case 'stopped': return t('row.stopped')
@@ -105,6 +106,7 @@ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): strin
/**
* Render one localized tool summary and lazily mounted result card.
* Preparation retains the icon and title without a summary or disclosure.
* @param props - tool state, summary, output, and navigation callbacks.
* @returns the tool disclosure.
*/
@@ -157,15 +159,16 @@ export const ToolRow = memo(function ToolRow({
const inputRaw = bodyRaw ?? null
const outputText = output ?? null
const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody ?? detailsBody
const expandable = inputRaw !== null || outputText !== null || card !== null
const expandable = state !== 'preparing' && (inputRaw !== null || outputText !== null || card !== null)
const open = expanded && expandable
const bodyText = useMemo(
() => open && card === null && inputRaw !== null ? formatToolBody(variant, inputRaw) : null,
[card, inputRaw, open, variant],
)
const status = stateStatus(state, t)
const running = state === 'running'
const normalSummary = terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary)
const running = state === 'running' || state === 'preparing'
const normalSummary = state === 'preparing' ? ''
: terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary)
// A failure keeps its first result line when available and otherwise turns
// the ordinary summary red. An interruption turns the tool-owned summary
// amber while retaining the business icon and hidden state announcement.
@@ -1,5 +1,6 @@
/** Shared narrowing for raw Tool call and result fields consumed by card models. */
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolCallBlock } from './tool-call-model.ts'
/** A parsed, in-window Tool call whose arguments are a JSON object. */
export interface ParsedToolCall {
@@ -11,10 +12,11 @@ const parsedCalls = new WeakMap<ToolCallBlock, ParsedToolCall | null>()
/**
* Parse the call head paired with one immutable Tool block.
* @param block - running or settled Tool block.
* @returns the Tool name and object arguments, or null when the call head or valid JSON object is unavailable.
* @param block - preparing, dispatched, or settled Tool block.
* @returns the Tool name and object arguments, or null during preparation or when valid arguments are unavailable.
*/
export function parsedToolCall(block: ToolCallBlock): ParsedToolCall | null {
if (!('kind' in block) && block.phase === 'preparing') return null
const cached = parsedCalls.get(block)
if (cached !== undefined || parsedCalls.has(block)) return cached ?? null
const call = 'kind' in block ? block.call : block
@@ -1,5 +1,5 @@
/** Todo changes relative to the preceding recorded write. */
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolCallBlock } from './tool-call-model.ts'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolDetailsModel } from '../components/ToolDetails.tsx'
import type { TodoBaseline } from './todo-history.ts'
@@ -5,9 +5,6 @@
* output and error material from the settled result node. A supported terminal
* call gets its expanded body from `terminalCardModel` instead.
*/
// The block union's defining home is runtime (fold-product types); this
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path'
@@ -18,7 +15,7 @@ export type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
export type ToolRowVariant = 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others'
/** Row lifecycle state used by summary styling and accessible status text. */
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
export type ToolRowState = 'preparing' | 'running' | 'ok' | 'error' | 'stopped'
/** Locale-neutral structured fact consumed only by the user-facing Tool row. */
export interface AutoReviewDenial {
@@ -93,6 +90,15 @@ export function classifyTool(toolName: string): ToolRowVariant {
return TOOL_VARIANTS[toolName] ?? 'others'
}
/**
* Select a tool-owned or generic title without reading arguments.
* @param toolName - wire tool name.
* @returns the localized title key.
*/
export function toolTitleKey(toolName: string): ToolTitleKey {
return TOOL_TITLE_KEYS[toolName] ?? VARIANT_TITLE_KEYS[classifyTool(toolName)]
}
/** Everything ToolRow needs, derived once from the frozen slice. */
export interface ToolRowModel {
variant: ToolRowVariant
@@ -229,14 +235,21 @@ export function formatToolBody(variant: ToolRowVariant, argsRaw: string): string
/**
* Derive the full row model from a frozen call slice.
* @param toolName - wire tool name (dispatch-supplied; survives windowless results).
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param block - preparing call, dispatched call, or result from the snapshot.
* @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
* @param home - host account home; a leftover POSIX home path displays as `~`.
* @returns the row model.
*/
export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string, home?: string): ToolRowModel {
const variant = classifyTool(toolName)
const titleKey = toolTitleKey(toolName)
const done = 'kind' in block
if (!done && block.phase === 'preparing') {
return {
variant, titleKey, state: 'preparing', summary: '', filePath: undefined,
bodyRaw: null, output: null, errorSummary: null, autoReviewDenial: null,
}
}
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
const state: ToolRowState = !done ? 'running'
: block.error?.code === 'interrupted' ? 'stopped'
@@ -244,10 +257,9 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
const base = argsRaw === ''
? block.callId
: abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home)
const toolTitleKey = TOOL_TITLE_KEYS[toolName]
// Others keeps the static "Tool call" title (figma literal); the real tool
// name rides the mutable summary slot unless the tool owns a specific title.
const summary = variant === 'others' && toolName !== '' && toolTitleKey === undefined
const summary = variant === 'others' && toolName !== '' && TOOL_TITLE_KEYS[toolName] === undefined
? `${toolName} · ${base}`
: base
// The empty string is "no text" for both derived result fields: a settled
@@ -258,7 +270,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
const bodyRaw = argsRaw === '' ? null : argsRaw
return {
variant,
titleKey: toolTitleKey ?? VARIANT_TITLE_KEYS[variant],
titleKey,
summary,
filePath: deriveFilePath(variant, argsRaw),
bodyRaw,
@@ -25,10 +25,11 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
}
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
export interface GenericToolCardProps extends ToolCallOwnerProps {
export type GenericToolCardProps = ToolCallOwnerProps & {
t: ToolTreeProps['t']
}
/** @param props - current tool stage and locale. @returns its preparation or dispatched card. */
export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd, home)
const autoReview = model.autoReviewDenial === null
@@ -52,7 +53,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect,
variant={model.variant}
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
title={t(model.titleKey)}
title={model.state === 'preparing' && model.titleKey === 'tool.title.generic' ? toolName : t(model.titleKey)}
summary={model.summary}
// Single-file tools never expose an args body — the path link is the only
// args interaction. A card is not an args body: a read/write/edit row is
@@ -146,7 +146,7 @@ export function AskQuestionRow({ toolName, block, inspect, useDisclosure, t }: A
// failed shape, and the abort keeps the shared stopped (amber) semantics of
// any other interrupted tool call.
const code = 'kind' in block ? block.error?.code : undefined
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const argsRaw = model.bodyRaw ?? ''
let summary = model.summary
let state = model.state
let transcript: AskQuestionCardModel | null = null
@@ -15,7 +15,8 @@ import {
terminalCardModel,
terminalFailed,
} from '../models/terminal-card-model.ts'
import { formatToolBody, toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
import { formatToolBody, toolRowModel, toolTitleKey, type ToolRowState } from '../models/tool-call-model.ts'
import { PreparingToolRow } from '../components/PreparingToolRow.tsx'
import { CONVERSATION_NS as NS } from '../../locale.ts'
import css from './bash-sample.module.css'
@@ -38,7 +39,13 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
* @param props - tool call, Session sources, locale, and inspection callback.
* @returns the Bash output row.
*/
export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSessions, inspect, useDisclosure, t }: BashRowProps) {
export const BashRow = memo(function BashRow(props: BashRowProps) {
if (props.phase === 'preparing') return <PreparingToolRow {...props}
icon={BASH_ICON} title={props.t(toolTitleKey(props.toolName))} />
return <StartedBashRow {...props} />
})
const StartedBashRow = memo(function StartedBashRow({ toolName, block, sessionId, useSessions, inspect, useDisclosure, t }: Exclude<BashRowProps, { phase: 'preparing' }>) {
const model = useMemo(() => toolRowModel(toolName, block), [toolName, block])
// An omitted shell workdir is the session workspace; relative values resolve
// against it before reaching the terminal primitive.
@@ -14,6 +14,8 @@ import { detailsCardModel } from '../models/details-card-model.ts'
import { parsedToolCall } from '../models/raw-tool-call.ts'
import { toolRowModel } from '../models/tool-call-model.ts'
type DetailsRowProps = ToolCallViewProps & PropsLocale<'conversation'>
const TITLE_KEYS = {
create_goal: 'tool.title.createGoal',
get_goal: 'tool.title.getGoal',
@@ -80,7 +82,7 @@ function detailIcon(toolName: string) {
* @param props - Tool call, row actions, and locale supplied by the keyed slot.
* @returns A Tool row with structured details or generic input/output.
*/
export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: ToolCallViewProps & PropsLocale<'conversation'>) {
export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: DetailsRowProps) {
const model = toolRowModel(toolName, block, cwd, home)
const locale = document.documentElement.lang
const details = useMemo(() => detailsCardModel(block, t, locale), [block, t, locale])
@@ -27,7 +27,8 @@ interface RowSummary {
extra: number
}
function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null {
function summarize(argsRaw: string | null, t: TodoRowProps['t']): RowSummary | null {
if (argsRaw === null) return null
let parsed: unknown
try {
parsed = JSON.parse(argsRaw)
@@ -54,8 +55,7 @@ export function TodoRow({ toolName, block, inspect, useDisclosure, useTodoHistor
const hasMore = useSession(snapshot => snapshot.hasMore)
const diff = useMemo(() => todoDiffModel(block, baseline, hasMore, t), [block, baseline, hasMore, t])
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw, t) ?? { text: model.summary, extra: 0 }
const summary = summarize(model.bodyRaw, t) ?? { text: model.summary, extra: 0 }
return (
<ToolRow
useDisclosure={useDisclosure}
@@ -33,13 +33,13 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
})
const runningCall = (argsRaw: string) =>
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] })
({ phase: 'start' as const, callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] })
const t = makeTranslate(zh, commonZh)
function rowProps(block: unknown): Parameters<typeof AskQuestionRow>[0] {
function rowProps(block: Parameters<typeof AskQuestionRow>[0]['block']): Parameters<typeof AskQuestionRow>[0] {
return {
useDisclosure, callId: 'c1', toolName: 'ask_user_question', block, t,
useDisclosure, callId: 'c1', toolName: 'ask_user_question', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), t,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type {
ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
ChatSnapshot, StartedToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { SlotTestRuntime, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
@@ -50,8 +50,8 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({
subCalls: [],
})
const runningCode = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000,
const runningCode = (callId: string): StartedToolCall => ({
phase: 'start' as const, callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000,
subCalls: [],
})
@@ -70,7 +70,7 @@ const subCall = (
function snapshotWith(
nodes: ToolResultNode[],
subCalls: readonly ToolCallBlock[],
runningCalls: RunningToolCall[] = [],
runningCalls: StartedToolCall[] = [],
): ChatSnapshot {
const nestedNodes = nodes.map(node => ({ ...node, subCalls }))
const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls }))
@@ -258,13 +258,13 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
const parent = 'call-live'
const runningSub: ToolCallBlock = {
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
phase: 'start' as const, callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
parentCallId: parent,
turn: 0, step: 0, time: 21_000, subCalls: [],
}
const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)]))
const view = mountApp(b.runtime)
// The nested row derives 'running' from the RunningToolCall shape — the
// The nested row derives 'running' from the StartedToolCall shape — the
// same data-state chrome (row sweep) a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull()
@@ -6,7 +6,7 @@ import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
@@ -33,9 +33,9 @@ function listStore() {
})
}
function bashProps(block: RunningToolCall | ToolResultNode): BashRowProps {
function bashProps(block: StartedToolCall | ToolResultNode): BashRowProps {
return {
useDisclosure, callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
useDisclosure, callId: 'c1', toolName: 'bash', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(listStore()),
t,
} as BashRowProps
@@ -61,7 +61,7 @@ describe('Tool presentation tails', () => {
}
const props: GenericToolCardProps = {
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
useDisclosure, callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
useDisclosure, callId: 'c5', toolName: 'todo_write', phase: 'result' as const, block: settled, openFile: vi.fn(), t,
}
const view = render(<GenericToolCard {...props} />)
expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull()
@@ -83,8 +83,8 @@ describe('Tool presentation tails', () => {
})
it('BashRow retains its business icon for failed and stopped states', () => {
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
const running: StartedToolCall = {
phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
turn: 1, step: 1, time: 1_000, subCalls: [],
}
const errorResult: ToolResultNode = {
@@ -63,7 +63,7 @@ describe('detailsCardModel', () => {
expect(detailsCardModel({ ...block, call: { name: 'create_goal', argsRaw: '{' } }, t, 'en')).toBeNull()
expect(detailsCardModel({ ...block, content: [{ type: 'text', text: 'partial {' }] }, t, 'en')).toBeNull()
expect(detailsCardModel({ ...block, content: [...block.content, { type: 'text', text: 'Extra result' }] }, t, 'en')).toBeNull()
expect(detailsCardModel({ callId: 'c1', name: 'create_goal', argsRaw: '{}', turn: 1, step: 1, time: 1000, subCalls: [] }, t, 'en')).toBeNull()
expect(detailsCardModel({ phase: 'start' as const, callId: 'c1', name: 'create_goal', argsRaw: '{}', turn: 1, step: 1, time: 1000, subCalls: [] }, t, 'en')).toBeNull()
expect(detailsCardModel({ ...block, parentCallId: 'parent' }, t, 'en')?.items[0]?.title).toBe(goal.objective)
})
})
@@ -5,7 +5,7 @@ import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/u
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -27,8 +27,8 @@ const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"h
const DIFFS = [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }]
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'edit', argsRaw: ARGS,
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'edit', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
@@ -159,9 +159,9 @@ describe('diffCardModel', () => {
})
describe('chat row diff body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
const ownerProps = (block: StartedToolCall | ToolResultNode): GenericToolCardProps => ({
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
useDisclosure, callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t,
useDisclosure, callId: 'c1', toolName: 'edit', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t,
})
it('the expanded body is the applied diff, capped tighter than the panel', () => {
@@ -187,7 +187,7 @@ describe('chat row diff body', () => {
const view = render(<GenericToolCard {...{
useDisclosure, callId: 'c1', toolName: 'some_tool', openFile: vi.fn(),
loadImage: vi.fn(() => Promise.reject(new Error('not used'))), t,
block: settled({
phase: 'result' as const, block: settled({
call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' },
meta: undefined,
}),
@@ -206,8 +206,8 @@ describe('FileMutationRow diff card', () => {
projectionsBySession: {},
})
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app',
const rowProps = (block: StartedToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), cwd: '/w/app',
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
} as FileMutationRowProps)
@@ -18,7 +18,7 @@ import { Context } from '@deepseek-ai/cordis'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
@@ -56,8 +56,8 @@ const withImage = (attachment: unknown) => [
{ type: 'image', attachment },
]
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read_image', argsRaw: ARGS,
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'read_image', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
@@ -275,11 +275,11 @@ describe('ReadImageRow keyed toolview', () => {
} as unknown as SessionListState)
const rowProps = (
block: RunningToolCall | ToolResultNode,
block: StartedToolCall | ToolResultNode,
renderSlot?: PropsRenderSlots<'tool.call.images'>['renderSlot'],
loader: MessageImageLoader = loadImage,
): Parameters<typeof ReadImageRow>[0] => ({
useDisclosure, callId: 'c1', toolName: 'read_image', block, openFile: vi.fn(), renderSlot, loadImage: loader,
useDisclosure, callId: 'c1', toolName: 'read_image', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), renderSlot, loadImage: loader,
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
} as Parameters<typeof ReadImageRow>[0])
@@ -7,7 +7,7 @@ import { Context } from '@deepseek-ai/cordis'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { CHAT_READ_MAX_LINES, readCallLine, readCardModel } from '../src/client/tool/models/read-card-model.ts'
@@ -54,8 +54,8 @@ const readMeta = (over?: Partial<ReadMetaFixture>): ReadMetaFixture => ({
const readContent = (body = 'export const a = 1'): string => `<path>src/a.ts</path>\n<type>file</type>\n<content>\n${body}\n</content>`
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS,
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
@@ -164,9 +164,9 @@ describe('readCallLine', () => {
})
describe('GenericToolCard read body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
const ownerProps = (block: StartedToolCall | ToolResultNode): GenericToolCardProps => ({
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
useDisclosure, callId: 'c1', toolName: 'read', block, openFile: vi.fn(), t,
useDisclosure, callId: 'c1', toolName: 'read', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t,
})
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
@@ -188,7 +188,7 @@ describe('GenericToolCard read body', () => {
it('a non-read tool renders the bare row with no read card', () => {
const view = render(<GenericToolCard {...({
useDisclosure, callId: 'c1', toolName: 'echo', block: settled({
useDisclosure, callId: 'c1', toolName: 'echo', phase: 'result' as const, block: settled({
call: { name: 'echo', argsRaw: '{"text":"x"}' }, meta: undefined,
}), openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))), t,
})} />)
@@ -210,8 +210,8 @@ describe('ReadRow keyed toolview', () => {
projectionsBySession: {},
})
const rowProps = (block: RunningToolCall | ToolResultNode): Parameters<typeof ReadRow>[0] => ({
useDisclosure, callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
const rowProps = (block: StartedToolCall | ToolResultNode): Parameters<typeof ReadRow>[0] => ({
useDisclosure, callId: 'c1', toolName: 'read', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
} as Parameters<typeof ReadRow>[0])
@@ -3,7 +3,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
import { cleanup, fireEvent, render } from '@testing-library/react'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -62,8 +62,8 @@ const pathsMeta = (over?: Partial<PathsMeta>): PathsMeta => ({
shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
})
const runningGrep = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
const runningGrep = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
@@ -175,9 +175,9 @@ describe('searchCardModel', () => {
})
describe('chat row search body (GenericToolCard fallback)', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): GenericToolCardProps => ({
const ownerProps = (block: StartedToolCall | ToolResultNode, toolName: string): GenericToolCardProps => ({
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), t,
useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t,
})
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
@@ -226,8 +226,8 @@ describe('chat row search body (GenericToolCard fallback)', () => {
})
describe('SearchRow keyed card', () => {
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): SearchRowProps => ({
useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t,
const rowProps = (block: StartedToolCall | ToolResultNode, toolName: string): SearchRowProps => ({
useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), sessionId: SID, t,
} as SearchRowProps)
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
@@ -5,7 +5,7 @@ import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/u
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -45,8 +45,8 @@ const shellArgs = (over: Record<string, unknown> = {}): string => JSON.stringify
command: 'ls -la', description: 'List files', ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: ARGS,
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
@@ -292,9 +292,9 @@ describe('terminalCardModel', () => {
})
describe('chat row terminal body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
const ownerProps = (block: StartedToolCall | ToolResultNode): GenericToolCardProps => ({
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
useDisclosure, callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t,
useDisclosure, callId: 'c1', toolName: 'bash', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t,
})
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
@@ -411,8 +411,8 @@ describe('BashRow terminal card', () => {
projectionsBySession: {},
})
const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({
useDisclosure, callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
const rowProps = (block: StartedToolCall | ToolResultNode): BashRowProps => ({
useDisclosure, callId: 'c1', toolName: 'bash', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
} as BashRowProps)
@@ -66,9 +66,9 @@ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResult
content: [], isError: false, subCalls: [], ...over,
})
function rowProps(block: unknown): TodoRowProps {
function rowProps(block: TodoRowProps['block']): TodoRowProps {
return {
useDisclosure, callId: 'c1', toolName: 'todo_write', block,
useDisclosure, callId: 'c1', toolName: 'todo_write', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }),
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
@@ -97,7 +97,7 @@ describe('TodoRow', () => {
it('omits the active clause when no item is in progress and reads running-call args', () => {
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
render(<TodoRow {...rowProps({ phase: 'start' as const, callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
@@ -110,7 +110,7 @@ describe('TodoRow', () => {
it('keeps non-ok execution states visible through the shared row states', () => {
const args = JSON.stringify({ todos: LIST })
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
const running = render(<TodoRow {...rowProps({ phase: 'start' as const, callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, subCalls: [] })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
running.unmount()
@@ -61,6 +61,17 @@ function props(
}
describe('ToolCallTree', () => {
it('dispatches preparation through the existing keyed toolview with no argument fields', () => {
const owners: ToolCallOwnerProps[] = []
const view = render(<ToolCallTree {...props({
phase: 'preparing', callId: 'preparing', name: 'write', turn: 1, step: 1, time: 1, subCalls: [],
}, undefined, owners)} />)
expect(owners[0]).toMatchObject({ phase: 'preparing', callId: 'preparing', toolName: 'write' })
expect(owners[0]?.block).not.toHaveProperty('argsRaw')
expect(view.container.querySelector('[data-chat-call-id="preparing"] [data-state="preparing"]')).not.toBeNull()
expect(view.queryByRole('button')).toBeNull()
})
it('forwards one stable disclosure Hook to nested calls and resets only their open state', () => {
const reset = createSnapshotStore(0)
const useDisclosure = bindDisclosure(reset)
@@ -139,13 +150,14 @@ describe('ToolCallTree', () => {
it('dispatches a running call by its wire name and forwards inspect', () => {
const owners: ToolCallOwnerProps[] = []
const block: ToolCallBlock = {
callId: 'running', name: 'bash', argsRaw: '{"command":"pwd"}',
phase: 'start' as const, callId: 'running', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 0, time: 1_000, subCalls: [],
}
const treeProps = props(block, undefined, owners)
render(<ToolCallTree {...treeProps} />)
expect(owners[0]?.toolName).toBe('bash')
expect(owners[0]?.phase).toBe('start')
const inspect = owners[0]?.inspect
expect(inspect).toBeDefined()
inspect?.()
@@ -3,7 +3,7 @@ import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-control
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
import type {
ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, ToolResultNode,
ChatConversationViewNode, ChatSnapshot, ConversationNode, StartedToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-chat/client'
function jsonFixture(value: unknown): JsonValue {
@@ -14,7 +14,7 @@ function jsonFixture(value: unknown): JsonValue {
/** Build the canonical Chat slice consumed by Tool rows and details tests. */
export function toolChatSnapshot(
settled: readonly ConversationNode[] = [],
running: readonly RunningToolCall[] = [],
running: readonly StartedToolCall[] = [],
): ChatSnapshot {
const roots = [...settled.filter(node => node.kind === 'tool-result'), ...running]
const nodes: ChatConversationViewNode[] = roots.map(root => ({
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
import { cleanup, fireEvent, render } from '@testing-library/react'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { localizeAutoReviewDenial, normalizeAutoReviewReason } from '../src/client/tool/models/auto-review-denial.ts'
@@ -21,8 +21,8 @@ afterEach(() => {
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
const running = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
@@ -494,9 +494,9 @@ describe('ToolRow', () => {
})
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
const props = (toolName: string, block: StartedToolCall | ToolResultNode): GenericToolCardProps => ({
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), t,
useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t,
})
it('renders the classified variant row from the frozen slice', () => {
@@ -22,6 +22,12 @@ describe('toolview type negatives (compile-time; body never runs)', () => {
}
void overreaching
const drifted = (props: ToolCallViewProps): ReactNode => {
if (props.phase === 'preparing') {
// @ts-expect-error preparation has no complete arguments
void props.block.argsRaw
// @ts-expect-error preparation has no result content
void props.block.content
}
// @ts-expect-error the Tool call union has no pre-parsed args member
void props.block.argsParsed
return null
@@ -3,7 +3,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
import { cleanup, fireEvent, render } from '@testing-library/react'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { webCardModel } from '../src/client/tool/models/web-card-model.ts'
@@ -49,8 +49,8 @@ const fetchMeta = (over?: Partial<FetchMeta>): FetchMeta => ({
url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
})
const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
const runningSearch = (over?: Partial<StartedToolCall>): StartedToolCall => ({
phase: 'start' as const, callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
turn: 1, step: 1, time: 1_000, subCalls: [], ...over,
})
@@ -124,13 +124,13 @@ describe('webCardModel', () => {
})
describe('chat row web body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolCallOwnerProps => ({
useDisclosure, callId: block.callId, toolName, block, openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
const ownerProps = (block: StartedToolCall | ToolResultNode, toolName: string): ToolCallOwnerProps => ({
useDisclosure, callId: block.callId, toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
})
// WebRow reads only toolName/block off the full runtime share plus the locale
// seat; the standard kit is unused, so the cast supplies the owner slice and
// `t` alone (as BashRow's tests do for the terminal card).
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): Parameters<typeof WebRow>[0] =>
const rowProps = (block: StartedToolCall | ToolResultNode, toolName: string): Parameters<typeof WebRow>[0] =>
({ ...ownerProps(block, toolName), t } as unknown as Parameters<typeof WebRow>[0])
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
@@ -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-trajectory/README.md
README.md: d15b1c7c6d605ba1b73180566a84fe29ab08530c
README.zh.md: 87cbae17c53a112cc62054a6150df9565a7bd8c0
README.md: 03553df4c69da9d5ec64416a8e65765d5dbcc904
README.zh.md: 7ce33007c40e77e9b288df988c4e7b7597576646
+2
View File
@@ -55,6 +55,8 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re
Native and nested PTC Tool results retain their raw structured error details. Failed records show the error code in the ledger and the error name and code in the inspector.
Tool records begin at durable tool/call events and use complete arguments. Chat's transient preparing stage does not create Trajectory tool rows or alter historical tool timing.
A complete appended prompt without a loaded request header appears as a standalone system row; only its known text is available, with no inferred request options or tool catalog. Prepending its request history replaces that standalone presentation without duplicating the prompt. In-history system prompt changes compare against the most recent request state, including earlier prompt updates without a new request header. Each request retains the prompt and change that applied at its own position. Surface replacements, including compaction, restore the last nonempty surviving system prompt even without a new system event; an unloaded prompt remains unavailable until its page arrives.
### Virtual rows
@@ -55,6 +55,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间
原生与嵌套 PTC 工具结果保留原始结构化错误详情。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。
工具记录从持久 tool/call 事件开始,使用完整参数。Chat 的瞬态 preparing 阶段不创建 Trajectory 工具行,也不改变历史工具计时。
完整的追加提示词在请求头未加载时显示为独立系统行;仅提供已知文本,不推断请求选项或工具目录。补入其请求历史后,该独立展示被替代而不重复提示词。历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。包括压缩在内的 surface 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。
### 虚拟行
@@ -168,6 +168,7 @@ export function deriveTrajectoryLayout(
if (startedAt !== null) callStartById.set(result.callId, startedAt)
}
for (const call of runningCalls) {
if (call.phase === 'preparing') continue
const startedAt = finiteTime(call.time)
if (startedAt !== null) callStartById.set(call.callId, startedAt)
}
@@ -503,7 +504,7 @@ export function deriveTrajectoryLayout(
const seenCalls = collectCallIds(turns)
for (const call of runningCalls) {
if (seenCalls.has(call.callId)) continue
if (call.phase === 'preparing' || seenCalls.has(call.callId)) continue
const laidList: LaidCell[] = [{
absTime: null,
toolName: call.name,
@@ -1020,6 +1021,7 @@ function expandSubCalls(
const out: LaidCell[] = []
let index = startIndex
for (const sub of subs) {
if (!('kind' in sub) && sub.phase === 'preparing') continue
const settled = 'kind' in sub
const resultPreview = settled ? summarizeResult(sub, t) : undefined
const laid: LaidCell = {
@@ -1,7 +1,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall,
ToolCallBlock, ToolResultNode,
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, StartedToolCall,
ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-tools/types'
import { trajectoryNode } from './trajectory-definition-common.ts'
@@ -11,6 +11,8 @@ import { trajectoryNode } from './trajectory-definition-common.ts'
* architecture/2026-08-09-client-conversation-node-assembly.md. */
const MAX_DEPTH = 256
type ToolCallBlock = StartedToolCall | ToolResultNode
interface ToolState {
readonly rootId: string
readonly calls: ReadonlyMap<string, ToolCallBlock>
@@ -28,11 +30,12 @@ interface DispatchData {
readonly content?: ToolResultNode['content']
}
function rootCall(match: ConversationMatch): RunningToolCall {
function rootCall(match: ConversationMatch): StartedToolCall {
if (match.event.type !== 'tool/call') {
throw new Error('trajectory-tool-call start requires tool/call')
}
return {
phase: 'start',
callId: String(match.event.data.callId),
name: match.event.data.name,
argsRaw: match.event.data.arguments,
@@ -45,7 +48,7 @@ function rootCall(match: ConversationMatch): RunningToolCall {
function rootResult(
match: ConversationMatch,
previous?: RunningToolCall,
previous?: StartedToolCall,
): ToolResultNode | undefined {
if (match.event.type !== 'tool/result') return undefined
const message = match.event.data.message
@@ -74,8 +77,9 @@ function locationStep(match: ConversationMatch): number {
return match.location.kind === 'step' ? match.location.step.step : 0
}
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
function childCall(match: ConversationMatch, data: DispatchData): StartedToolCall {
return {
phase: 'start',
callId: data.subCallId,
parentCallId: data.parentCallId,
name: data.name,
@@ -115,7 +115,7 @@ describe('deriveTrajectoryLayout', () => {
nodes: [],
partial: null,
runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
phase: 'start' as const, callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, subCalls: [],
}],
})
@@ -179,7 +179,7 @@ describe('deriveTrajectoryLayout', () => {
nodes: [],
partial: { ...partial, blocks: [] },
runningCalls: [{
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 1, time: 9_000, subCalls: [],
}],
})
@@ -540,7 +540,7 @@ describe('run_code sub-dispatch cells', () => {
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
const running = {
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
phase: 'start' as const, callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
turn: 0, step: 0, time: 6_400, subCalls: [],
}
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
@@ -216,7 +216,7 @@ describe('TrajectorySnapshotBuilder', () => {
contribution('tool', 7, {
kind: 'tool',
root: {
callId: 'call-edit',
phase: 'start' as const, callId: 'call-edit',
name: 'edit',
argsRaw: '{}',
turn: 1,
@@ -1428,7 +1428,7 @@ describe('TrajectoryView state', () => {
content: [],
isError: false,
subCalls: [{
callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}',
phase: 'start' as const, callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}',
turn: 1, step: 1, time: 3, subCalls: [],
}],
}, 'hidden-child'],
@@ -3815,14 +3815,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.images\', () => ctx.slots.register(\n { name: \'tool.call.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-tool/src/client/contract/slots.ts:35',
source: 'packages/client/ui-tool/src/client/contract/slots.ts:38',
},
{
key: 'tool.call.toolview',
kind: 'keyed',
scope: 'session',
summary: 'Keyed Tool call view dispatched by wire Tool name.',
doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'<tool name>\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through ToolCallOwnerProps.',
doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'<tool name>\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through explicit phase props; preparing inputs have no arguments.',
registerOptions: [
{
name: 'key',
@@ -3832,13 +3832,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
],
ownerProps: [
'/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Stable injected Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */\n useDisclosure: UseDisclosure\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /**\n * Open a Tool argument path. A view that knows which line the call was about\n * passes it, and the opened surface lands there.\n */\n openFile: (path: string, options?: OpenFileOptions) => void\n /**\n * Session-authorized image loader for the `tool.call.images` slot, supplied\n * by the chat node that owns this call. A composed chat node always\n * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool\n * layer never imports an attachment implementation nor handles URL\n * authorization.\n */\n loadIm /* …truncated — full shape in source */',
'/** Common owner callbacks and the data admitted at the current tool stage. */\nexport type ToolCallOwnerProps = ToolCallCommonProps & ToolCallPhaseProps',
],
ownerPropsReferences: [
'ChatNodeOwnerProps',
'MessageImageLoader',
'OpenFileOptions',
'UseDisclosure',
'ToolCallPhaseProps',
],
standardProps: [
'useResource: UseResource',
@@ -3918,7 +3915,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-tool/src/client/contract/slots.ts:21',
source: 'packages/client/ui-tool/src/client/contract/slots.ts:24',
},
{
key: 'tool.view.cordis',
@@ -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/extensions/ui-cordis/README.md
README.md: 1f3677d48eda13cad18a24ed6021f30535db686a
README.zh.md: f3855ce6f75efc7510abdaff8d211cec167d5117
README.md: 02ffc54ebb54b705d9e881d39f2b65d581a86257
README.zh.md: 235310346ba3e3f34e3567646e5521ee5ba72bd6
+2
View File
@@ -33,6 +33,8 @@ A `sidebar.footer.action` seat shows a badge counting what runs plus what waits;
### What the tool cards show
Preparing calls show their ordinary Cordis icon and title in one non-expandable row. This stage does not derive a package identity, read its live inventory, or mount its business view; those operations require the start/result props.
The `cordis_define` card is a record: the name and purpose the model wrote, the source it wrote, and whether the definition is running — no switch, no approval, and a pointer to the panel. The `cordis_run` card shows the mode, the plugin, package, and run ids, the outcome, and offers the package's own business view through the `tool.view.cordis` slot when the package registered one. Define, run, stop, and undefine cards retain their ordinary business glyph when they fail or are interrupted, including a correlated activation failure after a successful run receipt. All cards render from the recorded call and result, so replay shows the same card.
### Boundaries to plan around
@@ -33,6 +33,8 @@ kind: "package-reference"
### 工具卡片显示什么
准备中的调用显示为带普通 Cordis 图标与标题的不可展开单行。此阶段不派生包身份、不读取其当前清单,也不挂载其业务视图;这些操作需要 start/result props。
`cordis_define` 卡片是一份记录:模型写下的 name 与 purpose、它写的源码,以及该定义是否在跑——没有开关、没有审批,只有一句指向面板的指引。`cordis_run` 卡片显示模式、插件标识、包标识与运行标识、结果,并在包注册了业务视图时经 `tool.view.cordis` slot 提供它。define、run、stop 与 undefine 卡片在失败或中断时继续保留普通业务图标,包括调用回执成功后发生的关联激活失败。所有卡片都渲染会话记录下的 call 与 result,因此回放显示同一张卡。
### 需要规划的边界
@@ -7,12 +7,24 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import { cordisActionCard } from './card-model.ts'
import css from './CordisRunRow.module.css'
import { CordisPreparingRow } from './CordisPreparingRow.tsx'
/** Full action-card props composed by the keyed Tool slot. */
export type CordisActionRowProps = ToolCallViewProps & PropsLocale<'cordis'>
/** Render one Stop or Remove call with Cordis-owned localized copy. */
export function CordisActionRow({ callId, toolName, block, inspect, t }: CordisActionRowProps) {
export function CordisActionRow(props: CordisActionRowProps) {
if (props.phase === 'preparing') {
const remove = props.toolName === 'cordis_undefine'
return <CordisPreparingRow {...props}
icon={remove ? <IconTrashOutlineRegular size={14} /> : <IconStopFillRegular size={14} />}
title={props.t(remove ? 'row.removeTitle' : 'row.stopTitle')}
className={css.card} rowClassName={css.row} titleClassName={css.title} />
}
return <StartedCordisActionRow {...props} />
}
function StartedCordisActionRow({ callId, toolName, block, inspect, t }: Exclude<CordisActionRowProps, { phase: 'preparing' }>) {
const card = cordisActionCard(block)
const remove = toolName === 'cordis_undefine'
const summary = card.errorSummary ?? card.pluginId ?? callId
@@ -11,6 +11,7 @@ import type { CordisCardFace } from './slots.ts'
import { cordisVisibleStatus, type CordisVisibleStatus } from './status.ts'
import type { CordisKey } from './locales.ts'
import css from './CordisDefineRow.module.css'
import { CordisPreparingRow } from './CordisPreparingRow.tsx'
/** Full card props composed by the keyed Tool slot. */
export type CordisDefineRowProps = ToolCallViewProps & InjectFace<CordisCardFace> & PropsLocale<'cordis'>
@@ -35,9 +36,16 @@ function stateStatus(state: CordisToolState): CordisKey | null {
}
/** Render one immutable Package definition. */
export function CordisDefineRow({
export function CordisDefineRow(props: CordisDefineRowProps) {
if (props.phase === 'preparing') return <CordisPreparingRow {...props}
icon={<IconCodeOutlineRegular size={14} />} title={props.t('row.defineTitle')}
className={css.card} rowClassName={css.row} titleClassName={css.title} />
return <StartedCordisDefineRow {...props} />
}
function StartedCordisDefineRow({
callId, block, inspect, useInventory, useLoaded, t,
}: CordisDefineRowProps) {
}: Exclude<CordisDefineRowProps, { phase: 'preparing' }>) {
const card = cordisDefineCard(block)
const inventory = useInventory(snapshot => snapshot)
const loaded = useLoaded(snapshot => snapshot)
@@ -0,0 +1,25 @@
/** Argument-free Cordis tool prefix shared by its three card families. */
import type { ReactNode } from 'react'
import { DisclosureRow } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
type CordisPreparingRowProps = Pick<ToolCallViewProps, 'toolName' | 'useDisclosure'> & PropsLocale<'cordis'> & {
readonly icon: ReactNode
readonly title: string
readonly className?: string | undefined
readonly rowClassName?: string | undefined
readonly titleClassName?: string | undefined
}
/**
* Render a Cordis-owned prefix without disclosure.
* @param props - Cordis prefix, styling, and locale.
* @returns the preparation row.
*/
export function CordisPreparingRow({ toolName, useDisclosure, t, ...prefix }: CordisPreparingRowProps) {
const { toggle } = useDisclosure()
return <div data-tool={toolName} data-state="preparing" aria-label={t('a11y.preparing')}>
<DisclosureRow {...prefix} open={false} expandable={false} onToggle={toggle} running />
</div>
}
@@ -12,6 +12,7 @@ import type { CordisRunCardFace } from './slots.ts'
import { cordisVisibleStatus, type CordisVisibleStatus } from './status.ts'
import type { CordisKey } from './locales.ts'
import css from './CordisRunRow.module.css'
import { CordisPreparingRow } from './CordisPreparingRow.tsx'
/** Full Run-card props including its declared Package business-view child slot. */
export type CordisRunRowProps = ToolCallViewProps
@@ -32,10 +33,17 @@ const READING_LABELS = {
} as const satisfies Record<RunReading, CordisKey>
/** Render one activation result and, when eligible, its Package-owned view. */
export function CordisRunRow({
export function CordisRunRow(props: CordisRunRowProps) {
if (props.phase === 'preparing') return <CordisPreparingRow {...props}
icon={<IconCodeOutlineRegular size={14} />} title={props.t('row.runTitle')}
className={css.card} rowClassName={css.row} titleClassName={css.title} />
return <StartedCordisRunRow {...props} />
}
function StartedCordisRunRow({
callId, block, inspect, renderSlot, useInventory, useLoaded, useRunCards, useActiveRuns,
onObserveRunCard, t,
}: CordisRunRowProps) {
}: Exclude<CordisRunRowProps, { phase: 'preparing' }>) {
const card = cordisRunCard(block)
const inventory = useInventory(snapshot => snapshot)
const loaded = useLoaded(snapshot => snapshot)
@@ -1,11 +1,11 @@
/** Replay-stable view models for Cordis lifecycle Tool calls. */
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import type { StartedToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import type {
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, CordisDynamicRunMode,
} from './events.ts'
type Block = ToolCallViewProps['block']
type Block = StartedToolCallViewProps['block']
/** Lifecycle of the tool call itself. */
export type CordisToolState = 'running' | 'ok' | 'error' | 'stopped'
@@ -5,6 +5,7 @@ export const NS = 'cordis'
/** Simplified Chinese Cordis UI messages. */
export const zh = {
'row.defineTitle': '注册 Cordis 插件',
'a11y.preparing': '准备调用 Cordis 工具',
'row.runTitle': '运行 Cordis 插件',
'row.updateTitle': '更新 Cordis 插件',
'row.stopTitle': '停止 Cordis 插件',
@@ -69,6 +70,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** English Cordis UI messages. */
export const en = {
'row.defineTitle': 'Register Cordis Plugin',
'a11y.preparing': 'Preparing a Cordis tool call',
'row.runTitle': 'Run Cordis Plugin',
'row.updateTitle': 'Update Cordis Plugin',
'row.stopTitle': 'Stop Cordis Plugin',
@@ -2,14 +2,14 @@
// call/result slice.
import { describe, expect, it } from 'vitest'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { cordisActionCard, cordisDefineCard } from '../src/client/card-model.ts'
const ARGS = '{"name":"clock","purpose":"顶栏时钟","code":{"client":"return {}","host":"harness.handle(\'now\', () => Date.now())"}}'
function running(over: Partial<RunningToolCall> = {}): RunningToolCall {
function running(over: Partial<StartedToolCall> = {}): StartedToolCall {
return {
callId: 'call-1', name: 'cordis_define', argsRaw: ARGS, turn: 1, step: 1, time: 1_000,
phase: 'start' as const, callId: 'call-1', name: 'cordis_define', argsRaw: ARGS, turn: 1, step: 1, time: 1_000,
subCalls: [], ...over,
}
}
@@ -23,6 +23,23 @@ function failed(name: string): ToolResultNode {
const useValue = <T,>(value: T) => <Selected,>(selector: (snapshot: T) => Selected): Selected => selector(value)
describe('Cordis tool failure icons', () => {
it.each([
['cordis_define', CordisDefineRow], ['cordis_run', CordisRunRow],
['cordis_stop', CordisActionRow], ['cordis_undefine', CordisActionRow],
] as const)('%s prepares without invoking its inventory or execution hooks', (toolName, Component) => {
const unused = vi.fn(() => { throw new Error('preparation must not read execution details') })
const props = {
phase: 'preparing', toolName, callId: 'call', t,
useDisclosure: () => ({ expanded: false, setExpanded: vi.fn(), toggle: vi.fn() }),
block: { phase: 'preparing', name: toolName, callId: 'call', turn: 1, step: 1, time: 1, subCalls: [] },
useInventory: unused, useLoaded: unused, useRunCards: unused, useActiveRuns: unused,
} as Parameters<typeof CordisDefineRow>[0] & Parameters<typeof CordisRunRow>[0]
const view = render(<Component {...props} />)
expect(view.container.querySelector('[data-state="preparing"] svg')).not.toBeNull()
expect(view.queryByRole('button')).toBeNull()
expect(unused).not.toHaveBeenCalled()
})
it('retains the code glyph for define and run failures', () => {
const common = {
openFile: vi.fn(),
@@ -32,14 +49,14 @@ describe('Cordis tool failure icons', () => {
useLoaded: useValue([]),
}
const define = render(<CordisDefineRow {...{
...common, callId: 'call-cordis_define', toolName: 'cordis_define', block: failed('cordis_define'),
...common, callId: 'call-cordis_define', toolName: 'cordis_define', phase: 'result' as const, block: failed('cordis_define'),
} as unknown as Parameters<typeof CordisDefineRow>[0]} />)
expect(define.container.querySelector('[data-tool="cordis_define"] [data-disclosure-row] > :first-child svg')).not.toBeNull()
expect(define.container.querySelector('[data-tool="cordis_define"] [data-state]')).toBeNull()
define.unmount()
const run = render(<CordisRunRow {...{
...common, callId: 'call-cordis_run', toolName: 'cordis_run', block: failed('cordis_run'),
...common, callId: 'call-cordis_run', toolName: 'cordis_run', phase: 'result' as const, block: failed('cordis_run'),
renderSlot: vi.fn(), useRunCards: useValue(new Map()), useActiveRuns: useValue(new Map()),
onObserveRunCard: vi.fn(),
} as unknown as Parameters<typeof CordisRunRow>[0]} />)
@@ -83,7 +100,7 @@ describe('Cordis tool failure icons', () => {
content: [], isError: false, subCalls: [], meta: { pluginId, packageId, pluginRunId },
}
const view = render(<CordisRunRow {...{
callId: block.callId, toolName: 'cordis_run', block, openFile: vi.fn(), inspect: undefined, t,
callId: block.callId, toolName: 'cordis_run', phase: 'result', block, openFile: vi.fn(), inspect: undefined, t,
useInventory: useValue({
rows: [{
pluginId, agentId: 'session-1', packages: [{
@@ -106,7 +123,7 @@ describe('Cordis tool failure icons', () => {
it.each(['cordis_stop', 'cordis_undefine'] as const)('%s retains its action icon on failure', (toolName) => {
const view = render(<CordisActionRow {...{
callId: `call-${toolName}`, toolName, block: failed(toolName), inspect: undefined, t,
callId: `call-${toolName}`, toolName, phase: 'result' as const, block: failed(toolName), inspect: undefined, t,
} as unknown as Parameters<typeof CordisActionRow>[0]} />)
expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull()
expect(view.container.querySelector('[data-state="error"] [data-state]')).toBeNull()
@@ -118,7 +135,7 @@ describe('Cordis tool failure icons', () => {
error: { name: 'InterruptedError', code: 'interrupted' },
}
const view = render(<CordisActionRow {...{
callId: `call-${toolName}`, toolName, block, inspect: undefined, t,
callId: `call-${toolName}`, toolName, phase: 'result', block, inspect: undefined, t,
} as unknown as Parameters<typeof CordisActionRow>[0]} />)
expect(view.container.querySelector('[data-state="stopped"] svg')).not.toBeNull()
expect(view.container.querySelector('[data-state="stopped"] [data-state]')).toBeNull()
@@ -29,7 +29,7 @@ const row = (client: boolean): DynamicCordisInventoryRow => ({
describe('versioned Cordis card models', () => {
it('reads symmetric Host and Client source fields from cordis_define', () => {
const card = cordisDefineCard({
callId: 'call-1',
phase: 'start' as const, callId: 'call-1',
name: 'cordis_define',
argsRaw: JSON.stringify({
plugin: { kind: 'new', idPrefix: 'clock' },
@@ -78,7 +78,7 @@ describe('versioned Cordis card models', () => {
it('keeps the target identities while cordis_run waits for approval', () => {
const card = cordisRunCard({
callId: 'call-3',
phase: 'start' as const, callId: 'call-3',
name: 'cordis_run',
argsRaw: JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'update' }),
turn: 1,