feat(tool): show content preparation progress for file mutations

This commit is contained in:
imccyu committed 2026-09-23 19:48:45 +08:00
1 parent fb8bb16345
commit b660f72d4e
7 files changed
+77 -10

No files matched your search

@@ -85,6 +85,7 @@ export const zh = {
'tool.title.createGoal': '创建目标',
'tool.title.getGoal': '查看目标',
'tool.title.updateGoal': '更新目标',
'tool.preparing.content': '正在准备内容 {kilobytes}KB',
'tool.title.createSchedule': '创建定时任务',
'tool.title.listSchedules': '查看定时任务',
'tool.title.deleteSchedule': '删除定时任务',
@@ -438,6 +439,7 @@ export const en = {
'tool.title.createGoal': 'Create goal',
'tool.title.getGoal': 'View goal',
'tool.title.updateGoal': 'Update goal',
'tool.preparing.content': 'Preparing content {kilobytes}KB',
'tool.title.createSchedule': 'Create reminder',
'tool.title.listSchedules': 'List reminders',
'tool.title.deleteSchedule': 'Delete reminder',
+5 -1
View File
@@ -7,6 +7,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { ToolCallTree } from './tool/ToolCallTree.tsx'
import { bindToolCallArgumentsPartial } from './tool/tool-call-arguments-partial.ts'
import { CONVERSATION_NS as NS } from './locale.ts'
import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx'
import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx'
@@ -36,7 +37,10 @@ export function apply(ctx: ClientContext): void {
key: 'tool-call',
locale: NS,
children: {
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
'tool.call.toolview': {
kind: 'keyed', scope: 'session',
inject: { hooks: { toolCallArgumentsPartial: bindToolCallArgumentsPartial } },
},
},
inject: toolInject,
}, ToolCallTree))
@@ -1,10 +1,10 @@
/** Tool UI slot declarations and their composed component props. */
import type {
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SlotHookFactory,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
import type {
OpenFileOptions, PreparingToolCall, StartedToolCall,
AssistantChatData, 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'
@@ -21,7 +21,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* generic row. The owner supplies the call identity and frozen running
* or settled node through explicit phase props; preparing inputs have no arguments.
*/
'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
'tool.call.toolview': {
kind: 'keyed'
scope: 'session'
owner: ToolCallOwnerProps
hookContext: ToolCallHookContext
inject: ToolCallInjected
}
/**
* Durable images of a settled image-bearing Tool call, rendered through
* the attachment presentation plugin. The Tool layer never imports an
@@ -39,6 +45,23 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
/** Subscribe to this preparing call's raw argument prefix; other phases return an empty string. */
export type UseToolCallArgumentsPartial = () => string
/** Call-local sources supplied by the Tool tree to the slot's Hook binding. */
export interface ToolCallHookContext {
readonly callId: string
/** This call's Step source, present only while preparing. */
readonly assistant: HostObservable<Readonly<AssistantChatData> | undefined> | undefined
}
/** Framework-bound subscriptions available to atomic Tool views on demand. */
export interface ToolCallInjected {
hooks: {
toolCallArgumentsPartial: SlotHookFactory<'tool.call.toolview', UseToolCallArgumentsPartial>
}
}
/** Owner currency of the Tool image gallery slot: references plus the loader. */
export interface ToolImagesOwnerProps {
/** Durable references or submission-echo previews in result order. */
+1 -1
View File
@@ -2,5 +2,5 @@
export { apply, inject } from './apply.ts'
export type {
StartedToolCallViewProps, ToolCallCommonProps, ToolCallOwnerProps, ToolCallPhaseProps, ToolCallViewProps,
ToolHostInfoInjected, ToolTreeProps,
ToolCallHookContext, ToolCallInjected, ToolHostInfoInjected, ToolTreeProps, UseToolCallArgumentsPartial,
} from './contract/slots.ts'
@@ -1,7 +1,7 @@
/** 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, ToolCallPhaseProps, ToolTreeProps } from '../contract/slots.ts'
import type { ToolCallHookContext, 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'
@@ -18,14 +18,19 @@ function callName(call: ToolCallPhaseProps): string {
/** One atomic call dispatched through the Tool-owned keyed slot. */
const ToolCall = memo(function ToolCall({
renderSlot, callId, toolName, call, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children,
renderSlot, callId, toolName, call, assistant, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children,
}: Pick<ToolTreeProps, 'renderSlot' | 'openFile' | 'cwd' | 'inspectCall' | 'loadImage' | 'useDisclosure' | 't'> & {
callId: string
toolName: string
call: ToolCallPhaseProps
assistant: ToolCallHookContext['assistant']
home?: string | undefined
children?: ReactNode
}) {
const preparing = call.phase === 'preparing'
const hookContext = useMemo<ToolCallHookContext>(() => ({
callId, assistant: preparing ? assistant : undefined,
}), [assistant, callId, preparing])
const owner: ToolCallOwnerProps = useMemo(() => ({
callId,
toolName,
@@ -51,6 +56,7 @@ const ToolCall = memo(function ToolCall({
? <GenericToolCard {...owner} t={t} />
: renderSlot('tool.call.toolview', owner, {
entryKey: toolName,
hookContext,
fallback: <GenericToolCard {...owner} t={t} />,
})}
{children}
@@ -59,9 +65,10 @@ const ToolCall = memo(function ToolCall({
})
const ToolCallBranch = memo(function ToolCallBranch({
renderSlot, block, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t,
renderSlot, block, assistant, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t,
}: Pick<ToolTreeProps, 'renderSlot' | 'cwd' | 'openFile' | 'inspectCall' | 'loadImage' | 'useDisclosure' | 't'> & {
block: ToolCallBlock
assistant: ToolCallHookContext['assistant']
home?: string | undefined
}) {
const call = useMemo(() => toolCallPhase(block), [block])
@@ -71,6 +78,7 @@ const ToolCallBranch = memo(function ToolCallBranch({
callId={call.block.callId}
toolName={callName(call)}
call={call}
assistant={assistant}
openFile={openFile}
cwd={cwd}
home={home}
@@ -86,6 +94,7 @@ const ToolCallBranch = memo(function ToolCallBranch({
key={child.callId}
renderSlot={renderSlot}
block={child}
assistant={assistant}
cwd={cwd}
home={home}
openFile={openFile}
@@ -111,10 +120,12 @@ export function ToolCallTree({
renderSlot, node, cwd, openFile, inspectCall, loadImage, useDisclosure, useHostInfo, t,
}: ToolTreeProps) {
const home = useHostInfo(info => info.home)
const assistant = node.location.kind === 'step' ? node.location.step.data.source('assistant-step') : undefined
return (
<ToolCallBranch
renderSlot={renderSlot}
block={node.data.root}
assistant={assistant}
cwd={cwd}
home={home}
openFile={openFile}
@@ -0,0 +1,23 @@
/** Call-local argument subscription bound by the atomic Tool slot. */
import { useSyncExternalStore } from 'react'
import type { ToolCallInjected } from '../contract/slots.ts'
const subscribeEmpty = (): (() => void) => () => {}
/**
* Bind one call without subscribing until its component invokes the Hook.
* @param _standard - framework-provided scope props.
* @param context - the preparing call's Step source and identity.
* @returns a Hook that reads only this call's raw argument prefix.
*/
export const bindToolCallArgumentsPartial: ToolCallInjected['hooks']['toolCallArgumentsPartial'] = (
_standard, { assistant, callId },
) => {
const getSnapshot = (): string => {
const block = assistant?.getSnapshot()?.blocks.find(candidate => candidate.kind === 'tool-call' && candidate.callId === callId)
return block?.kind === 'tool-call' ? block.argsRaw : ''
}
return function useToolCallArgumentsPartial() {
return useSyncExternalStore(assistant?.subscribe ?? subscribeEmpty, getSnapshot)
}
}
@@ -12,9 +12,13 @@ type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'>
/**
* Lets users expand an applied file diff and open the reported path.
*/
export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: FileMutationRowProps) {
export function FileMutationRow({ toolName, block, phase, cwd, home, openFile, inspect, useDisclosure, useToolCallArgumentsPartial, t }: FileMutationRowProps) {
const raw = useToolCallArgumentsPartial()
const model = toolRowModel(toolName, block, cwd, home)
const diff = diffCardModel(block)
const summary = phase === 'preparing'
? t('tool.preparing.content', { kilobytes: Math.round(raw.length / 1024) })
: model.summary
return (
<ToolRow
useDisclosure={useDisclosure}
@@ -23,7 +27,7 @@ export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect,
toolName={toolName}
icon={<IconEditOutlineRegular size={14} />}
title={t(model.titleKey)}
summary={model.summary}
summary={summary}
output={model.output}
errorSummary={model.errorSummary}
diff={diff}