diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 4e249a1621..a37c471ad9 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md -README.md: 8f0dfd0bc0222bb6ea428deb0dd6769f7347e41c -README.zh.md: bd4681022f1215a3e0dcda48b73c911b5bc6a283 +README.md: 39b05236deae913d8be31be7846cac6f853b7eca +README.zh.md: e06ec350ad299593c82a216949fcdfa713122387 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 8f0dfd0bc0..39b05236de 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -63,7 +63,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Rendering contract -`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:"` and `data-chat-call-id` DOM contract used for paging and selection. The Tool node keeps the same callId across all three stages. +`ToolCallTree` receives one Tool node, Session `cwd`, and navigation callbacks. Each branch receives a stable block and memoizes its explicit phase props, so changing one subcall leaves unchanged sibling branches unrendered. It 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:"` 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. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index bd4681022f..e06ec350ad 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -63,7 +63,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 渲染约定 -`ToolCallTree` 接收一个 Tool 节点、会话 `cwd` 和导航回调。它从根调用数据生成显式阶段 props,并通过 `tool.call.toolview` 按工具名分发。已派发的根调用保留递归 `subCalls`,准备阶段没有子调用。每个根调用和子调用包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和选择使用。Tool 节点在三个阶段保持同一个 callId。 +`ToolCallTree` 接收一个 Tool 节点、会话 `cwd` 和导航回调。每个分支接收稳定的 block,并缓存显式阶段 props,因此一个子调用变化不会重渲染未变化的兄弟分支。它通过 `tool.call.toolview` 按工具名分发。已派发的根调用保留递归 `subCalls`,准备阶段没有子调用。每个根调用和子调用包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和选择使用。Tool 节点在三个阶段保持同一个 callId。 Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调用及嵌套调用。工具行在拥有展开正文的位置调用它,中间 renderer 不订阅。每次调用拥有独立展开状态,外层轮次收起时重置该状态,不替换 React 身份;展示模式切换保留该状态。 diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index d799dd384f..d6419aa205 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -59,11 +59,12 @@ const ToolCall = memo(function ToolCall({ }) const ToolCallBranch = memo(function ToolCallBranch({ - renderSlot, call, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t, + renderSlot, block, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t, }: Pick & { - call: ToolCallPhaseProps + block: ToolCallBlock home?: string | undefined }) { + const call = useMemo(() => toolCallPhase(block), [block]) return ( info.home) - const call = useMemo(() => toolCallPhase(node.data.root), [node.data.root]) return ( { + it('dispatches only the parent and changed child among 100 sibling calls', () => { + const children = Array.from({ length: 100 }, (_, index) => root(`child-${index}`, { name: 'custom_child', argsRaw: '{}' })) + const block = { ...root('parent', { name: 'custom_parent', argsRaw: '{}' }), subCalls: children } + const owners: ToolCallOwnerProps[] = [] + const initial = props(block, undefined, owners) + const view = render() + expect(owners).toHaveLength(101) + owners.length = 0 + const changed = { ...children[42]!, isError: true } + const next = { ...block, subCalls: children.map((child, index) => index === 42 ? changed : child) } + view.rerender() + expect(owners.map(owner => owner.callId)).toEqual(['parent', 'child-42']) + }) + it('dispatches preparation through the existing keyed toolview with no argument fields', () => { const owners: ToolCallOwnerProps[] = [] const view = render(