From af59eb3ce122d538503dd4352788cb09e80a91c4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 15:05:50 -0700 Subject: [PATCH 1/2] feat(research-web): render tool calls as generative-UI cards Co-Authored-By: Claude Opus 4.8 --- .../web/app/components/ToolCallCard.tsx | 96 +++++++++++++++++++ examples/research/web/app/page.tsx | 2 + 2 files changed, 98 insertions(+) create mode 100644 examples/research/web/app/components/ToolCallCard.tsx diff --git a/examples/research/web/app/components/ToolCallCard.tsx b/examples/research/web/app/components/ToolCallCard.tsx new file mode 100644 index 00000000..45e62cc4 --- /dev/null +++ b/examples/research/web/app/components/ToolCallCard.tsx @@ -0,0 +1,96 @@ +"use client" +import { useRenderTool } from "@copilotkit/react-core/v2" + +// Notes (verified against installed @copilotkit/react-core@1.62.3 types — +// examples/research/web/node_modules/@copilotkit/react-core/dist/copilotkit-Bp6BD8xe.d.mts): +// +// - The registration hook is `useRenderTool` (NOT `useRenderToolCall` — that +// one takes no args and returns a `({toolCall, toolMessage}) => ReactElement` +// render *function* used internally by CopilotKit's own message view; it is +// not a registration API). `useRenderTool` is called under ``, +// the same way `useInterrupt` is used in PermissionInterrupt.tsx. +// - Wildcard registration: pass `{ name: "*", render, agentId? }` — the "*" +// overload is documented as "used as a fallback when no exact name-matched +// renderer is registered for a tool call" (src/v2/hooks/use-render-tool.d.ts). +// `WildcardToolCallRender`/`defineToolCallRenderer` are the analogous +// *prop*-based API (for ``), not needed +// here since the hook form matches the rest of this app's components. +// - Render prop field names for `useRenderTool`'s wildcard overload are +// `{ name, toolCallId, parameters, status, result }` — note the args field +// is called `parameters` here (not `args`; `args` is only the field name on +// the sibling `ReactToolCallRenderer`/`defineToolCallRenderer` types used by +// the prop-based API). +// - `status` is the string union `"inProgress" | "executing" | "complete"` +// (plain string literals) for `useRenderTool` — NOT the `ToolCallStatus` +// enum (`InProgress`/`Executing`/`Complete`), which belongs to the +// prop-based `ReactToolCallRenderer` render props instead. +// - `result` is `string | undefined`, populated only once `status === "complete"`. +// +// With no agentId, this binds to CopilotKit's default agent id ("default"), +// which the runtime route registers as our Dawn /research agent — same as +// PermissionInterrupt.tsx and the other panels. +function summarizeArgs(name: string, parameters: unknown): string { + const p = (parameters ?? {}) as Record + switch (name) { + case "searchCorpus": + return typeof p.query === "string" ? p.query : JSON.stringify(p) + case "readDoc": + return typeof p.path === "string" ? p.path : JSON.stringify(p) + case "runBash": + return typeof p.command === "string" ? p.command : JSON.stringify(p) + default: + return JSON.stringify(p) + } +} + +export function ToolCallCard() { + useRenderTool( + { + name: "*", + render: ({ name, status, parameters, result }) => ( +
+ + {name} + + + {status === "complete" ? "done" : status === "executing" ? "running…" : "preparing…"} + +
+ {summarizeArgs(name, parameters)} +
+ {status === "complete" && result != null && ( +
+              {result.slice(0, 400)}
+            
+ )} +
+ ), + }, + [], + ) + return null +} diff --git a/examples/research/web/app/page.tsx b/examples/research/web/app/page.tsx index 72d2223c..75ddaaa4 100644 --- a/examples/research/web/app/page.tsx +++ b/examples/research/web/app/page.tsx @@ -4,6 +4,7 @@ import { MemoryCandidates } from "./components/MemoryCandidates" import { PermissionInterrupt } from "./components/PermissionInterrupt" import { PlanPanel } from "./components/PlanPanel" import { SubagentActivity } from "./components/SubagentActivity" +import { ToolCallCard } from "./components/ToolCallCard" // Notes (verified against installed @copilotkit/react-core@1.62.3 types — see // examples/chat/web/app/page.tsx for the original investigation): @@ -22,6 +23,7 @@ export default function Home() { return ( +
From 09b217f1896f9fc76bd726bf53166f02ac863822 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 16 Jul 2026 14:42:31 -0700 Subject: [PATCH 2/2] fix(web): throttle useAgent re-renders; add tool-call cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The research UI froze during a full run: useAgent re-renders are UNTHROTTLED by default (CopilotKit's documented default), so every panel and the sidebar transcript re-rendered on every streamed token — hundreds per run — pegging the renderer. Set defaultThrottleMs={100} on in both demos. Also adds a wildcard tool-call card (useRenderTool name:'*') to the research UI, unwrapping Dawn's JSON-string args and the serialized LangChain ToolMessage so cards show the real argument and output instead of double-encoded internals. Co-Authored-By: Claude Opus 4.8 --- examples/chat/web/app/page.tsx | 2 +- .../web/app/components/ToolCallCard.tsx | 72 +++++++++++++++---- examples/research/web/app/page.tsx | 7 +- 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/examples/chat/web/app/page.tsx b/examples/chat/web/app/page.tsx index ff05284a..a1db90a0 100644 --- a/examples/chat/web/app/page.tsx +++ b/examples/chat/web/app/page.tsx @@ -18,7 +18,7 @@ import { TodosPanel } from "./components/TodosPanel" // - `labels` is `Partial`, whose header title field is `modalHeaderTitle`. export default function Home() { return ( - +
diff --git a/examples/research/web/app/components/ToolCallCard.tsx b/examples/research/web/app/components/ToolCallCard.tsx index 45e62cc4..3d23fe48 100644 --- a/examples/research/web/app/components/ToolCallCard.tsx +++ b/examples/research/web/app/components/ToolCallCard.tsx @@ -29,8 +29,45 @@ import { useRenderTool } from "@copilotkit/react-core/v2" // With no agentId, this binds to CopilotKit's default agent id ("default"), // which the runtime route registers as our Dawn /research agent — same as // PermissionInterrupt.tsx and the other panels. -function summarizeArgs(name: string, parameters: unknown): string { +/** + * Dawn delivers tool args as a JSON *string* under `input` (that's how the + * agent-adapter serializes them), so `parameters` arrives as + * `{ input: '{"path":"corpus/x.md"}' }`. Unwrap it so the card can show the + * real argument instead of double-encoded JSON. + */ +function parseArgs(parameters: unknown): Record { const p = (parameters ?? {}) as Record + if (typeof p.input === "string") { + try { + const inner = JSON.parse(p.input) + if (inner && typeof inner === "object") return inner as Record + } catch { + // Not JSON — fall through and show the raw string. + } + } + return p +} + +/** + * Tool results arrive as a serialized LangChain `ToolMessage` + * (`{ lc, type, id: [...], kwargs: { content } }`). Pull out the content so the + * card shows the actual output rather than LangChain internals. + */ +function parseResult(result: string | undefined): string | undefined { + if (!result) return undefined + try { + const parsed = JSON.parse(result) as { kwargs?: { content?: unknown } } + const content = parsed?.kwargs?.content + if (typeof content === "string") return content + if (content != null) return JSON.stringify(content, null, 2) + } catch { + // Not JSON — show it as-is. + } + return result +} + +function summarizeArgs(name: string, parameters: unknown): string { + const p = parseArgs(parameters) switch (name) { case "searchCorpus": return typeof p.query === "string" ? p.query : JSON.stringify(p) @@ -38,6 +75,8 @@ function summarizeArgs(name: string, parameters: unknown): string { return typeof p.path === "string" ? p.path : JSON.stringify(p) case "runBash": return typeof p.command === "string" ? p.command : JSON.stringify(p) + case "task": + return typeof p.subagent === "string" ? `→ ${p.subagent}` : JSON.stringify(p) default: return JSON.stringify(p) } @@ -74,19 +113,24 @@ export function ToolCallCard() {
{summarizeArgs(name, parameters)}
- {status === "complete" && result != null && ( -
-              {result.slice(0, 400)}
-            
- )} + {status === "complete" && + (() => { + const content = parseResult(result) + if (!content) return null + return ( +
+                  {content.slice(0, 400)}
+                
+ ) + })()}
), }, diff --git a/examples/research/web/app/page.tsx b/examples/research/web/app/page.tsx index 75ddaaa4..ecdc16dc 100644 --- a/examples/research/web/app/page.tsx +++ b/examples/research/web/app/page.tsx @@ -19,9 +19,14 @@ import { ToolCallCard } from "./components/ToolCallCard" // under "default", so the sidebar, useAgent, and useInterrupt all bind to it with // no per-component agentId. // - `labels` is `Partial`, whose header title field is `modalHeaderTitle`. +// - `defaultThrottleMs` coalesces the useAgent re-renders that every panel and the +// sidebar transcript get from OnMessagesChanged/OnStateChanged. It defaults to +// UNTHROTTLED, and a full research run streams hundreds of events (coordinator +// tokens + ~200 subagent events), which pegs the renderer. 100ms keeps the UI +// live-feeling while capping re-renders at ~10/sec. export default function Home() { return ( - +