feat: sync tool result contentful display
This commit is contained in:
Generated
+1264
-2
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^5.6.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import WorkerRunBubble from "@/components/WorkerRunBubble";
|
||||
import type { WorkerRunGroup } from "@/components/WorkerRunBubble";
|
||||
import ChartToolDetail, {
|
||||
type ChartToolEntry,
|
||||
} from "@/components/charts/ChartToolDetail";
|
||||
|
||||
export interface ImageContent {
|
||||
type: "image_url";
|
||||
@@ -205,7 +208,7 @@ export function toolHex(name: string): string {
|
||||
}
|
||||
|
||||
export function ToolActivityRow({ content }: { content: string }) {
|
||||
let tools: { name: string; done: boolean }[] = [];
|
||||
let tools: ChartToolEntry[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
tools = parsed.tools || [];
|
||||
@@ -239,7 +242,13 @@ export function ToolActivityRow({ content }: { content: string }) {
|
||||
if (counts.done > 0) donePills.push({ name, count: counts.done });
|
||||
}
|
||||
|
||||
// Per-call chart embeds: chart_render's result envelope carries the
|
||||
// spec back, so the chat renders the same chart the server
|
||||
// rasterized to PNG. Other tools stay pill-only by design.
|
||||
const chartDetails = tools.filter((t) => t.name.startsWith("chart_"));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex gap-3 pl-10">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{runningPills.map((p) => {
|
||||
@@ -286,6 +295,13 @@ export function ToolActivityRow({ content }: { content: string }) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{chartDetails.map((t, idx) => (
|
||||
<ChartToolDetail
|
||||
key={t.callKey ?? `${t.name}-${idx}`}
|
||||
entry={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Per-call detail row for ``chart_*`` tool calls.
|
||||
*
|
||||
* The canonical embedding mechanism: when the agent invokes
|
||||
* ``chart_render``, the runtime stores the result envelope in
|
||||
* ``events.jsonl``; ``chat-helpers.replayEvent`` retains it and the
|
||||
* chat panel dispatches it here. We read ``result.spec`` and mount
|
||||
* the live renderer; ``result.file_url`` becomes the download link.
|
||||
*
|
||||
* Rules baked in:
|
||||
* - The chart is reconstructed FROM THE TOOL RESULT, not from any
|
||||
* markdown fence the agent might have written. Calling the tool
|
||||
* IS the embedding — there's nothing else to remember.
|
||||
* - The chart survives session reload because the spec lives in
|
||||
* events.jsonl alongside the tool_call_completed event.
|
||||
* - The downloadable PNG lives at ``result.file_url`` (a ``file://``
|
||||
* URI on the runtime host). The web frontend can't open file://
|
||||
* directly; we surface ``file_path`` as text and give a Copy
|
||||
* button so the user can paste it into a file manager. (The
|
||||
* desktop renderer has an Electron IPC bridge — not available
|
||||
* in OSS.)
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { Copy, Loader2, Check } from "lucide-react";
|
||||
|
||||
// Lazy chunks so non-chart messages don't drag in echarts/mermaid.
|
||||
const EChartsBlock = lazy(() => import("./EChartsBlock"));
|
||||
const MermaidBlock = lazy(() => import("./MermaidBlock"));
|
||||
|
||||
export interface ChartToolEntry {
|
||||
name: string;
|
||||
done: boolean;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
callKey?: string;
|
||||
}
|
||||
|
||||
interface ChartResult {
|
||||
kind?: "echarts" | "mermaid";
|
||||
spec?: unknown;
|
||||
file_path?: string;
|
||||
file_url?: string;
|
||||
title?: string;
|
||||
error?: string;
|
||||
// Width/height come back from the server tool but are NOT displayed
|
||||
// in the footer. Kept here so the live in-chat render can match the
|
||||
// spec's native aspect ratio instead of forcing a 16:9 box that
|
||||
// clips wide dashboards.
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
function asResult(v: unknown): ChartResult {
|
||||
if (v && typeof v === "object") return v as ChartResult;
|
||||
return {};
|
||||
}
|
||||
|
||||
export default function ChartToolDetail({ entry }: { entry: ChartToolEntry }) {
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||
|
||||
// Still running: show a tiny inline spinner. Charts render fast (a
|
||||
// few hundred ms), so a full skeleton would flash and feel janky.
|
||||
if (!entry.done) {
|
||||
return (
|
||||
<div className="pl-10 mt-1.5">
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
|
||||
<span>rendering chart…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const result = asResult(entry.result);
|
||||
|
||||
if (result.error) {
|
||||
// Errors are intentionally NOT shown to the user — the agent sees
|
||||
// them in the tool result envelope and is expected to retry with a
|
||||
// fixed spec.
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = result.kind;
|
||||
const spec = result.spec;
|
||||
if (!kind || spec === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCopyPath = async () => {
|
||||
if (!result.file_path) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.file_path);
|
||||
setCopyState("copied");
|
||||
window.setTimeout(() => setCopyState("idle"), 2000);
|
||||
} catch {
|
||||
// Clipboard API unavailable (insecure context); silently no-op.
|
||||
}
|
||||
};
|
||||
|
||||
// Honor the spec's native aspect ratio when both dimensions are
|
||||
// known (the server tool always returns them).
|
||||
const aspectRatio =
|
||||
result.width && result.height ? result.width / result.height : undefined;
|
||||
|
||||
return (
|
||||
<div className="pl-10 mt-1.5 max-w-5xl">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>loading chart engine…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{kind === "echarts" ? (
|
||||
<EChartsBlock spec={spec} aspectRatio={aspectRatio} />
|
||||
) : kind === "mermaid" ? (
|
||||
<MermaidBlock source={typeof spec === "string" ? spec : ""} />
|
||||
) : (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
unknown chart kind: {String(kind)}
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
{/* Footer: title + path-copy. The PNG lives on the runtime host;
|
||||
web browsers can't open file:// URIs from a hosted page, so
|
||||
we surface the path as a copyable string instead of a fake
|
||||
Download button. */}
|
||||
<div className="flex items-center justify-between mt-2 px-1 text-[10.5px] text-muted-foreground/80">
|
||||
<span className="truncate min-w-0 flex-1">
|
||||
{result.title || kind}
|
||||
</span>
|
||||
{result.file_path && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyPath}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground transition shrink-0 cursor-pointer"
|
||||
title={
|
||||
copyState === "copied"
|
||||
? "Copied to clipboard"
|
||||
: `Copy path: ${result.file_path}`
|
||||
}
|
||||
>
|
||||
{copyState === "copied" ? (
|
||||
<Check className="w-3 h-3 text-primary" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
{copyState === "copied" ? "Copied" : "Copy path"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Live ECharts renderer for the chat bubble.
|
||||
*
|
||||
* Mounts an ECharts instance into a sized div and feeds it the spec
|
||||
* the agent passed to `chart_render`. The same spec is rendered
|
||||
* server-side to a PNG; the live render is the in-chat experience,
|
||||
* the PNG is the downloadable.
|
||||
*
|
||||
* - Lazy-loaded via dynamic import so non-chart messages don't pay
|
||||
* the ~1 MB bundle cost.
|
||||
* - SVG renderer (`{ renderer: 'svg' }`) for crisp scaling and lower
|
||||
* memory than canvas. Looks identical at chat-bubble sizes.
|
||||
* - Resize handled via ResizeObserver; charts adapt to the bubble's
|
||||
* width while keeping a fixed aspect ratio.
|
||||
* - Error boundary inside the component itself: invalid specs render
|
||||
* a tiny "spec invalid" pill with a copy-spec button so the agent
|
||||
* can self-correct on the next turn.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { buildOpenHiveTheme } from "./openhiveTheme";
|
||||
|
||||
interface Props {
|
||||
spec: unknown;
|
||||
/** Aspect ratio kept while the width adapts to the bubble. Defaults
|
||||
* to 16:9 — the standard chart shape that fits in slide decks. */
|
||||
aspectRatio?: number;
|
||||
/** Hard cap on rendered height (px). Prevents very-tall charts from
|
||||
* dominating the chat scroll. */
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
const _themeRegistered: Record<"light" | "dark", boolean> = {
|
||||
light: false,
|
||||
dark: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect the user's current UI theme from the DOM. The OpenHive
|
||||
* desktop app applies a `dark` class to <html> in dark mode (see
|
||||
* index.css). We use the same signal here so the live chart matches
|
||||
* the surrounding chat — neither the agent nor the caller picks the
|
||||
* theme, and the PNG download is rendered server-side from the same
|
||||
* source of truth (HIVE_DESKTOP_THEME env, set by Electron from
|
||||
* nativeTheme.shouldUseDarkColors).
|
||||
*/
|
||||
function useDocumentTheme(): "light" | "dark" {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() =>
|
||||
document.documentElement.classList.contains("dark") ? "dark" : "light",
|
||||
);
|
||||
useEffect(() => {
|
||||
const obs = new MutationObserver(() => {
|
||||
setTheme(
|
||||
document.documentElement.classList.contains("dark") ? "dark" : "light",
|
||||
);
|
||||
});
|
||||
obs.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
return theme;
|
||||
}
|
||||
|
||||
export default function EChartsBlock({
|
||||
spec,
|
||||
aspectRatio = 16 / 9,
|
||||
maxHeight = 480,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<unknown>(null); // echarts.ECharts instance, kept untyped to avoid coupling the type import
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Theme follows the user's OpenHive UI mode automatically. Same
|
||||
// signal feeds the server-side PNG render via HIVE_DESKTOP_THEME, so
|
||||
// live chart and downloaded file always match.
|
||||
const theme = useDocumentTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
let disposed = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const echarts = await import("echarts");
|
||||
if (disposed || !containerRef.current) return;
|
||||
// Register the OpenHive brand theme once per (theme, mode) so
|
||||
// bar/line/etc. inherit our palette + cozy spacing instead of
|
||||
// ECharts' generic-web-2010 defaults. Theme matches the
|
||||
// server-side render via tools/src/chart_tools/theme.py.
|
||||
const themeName = theme === "dark" ? "openhive-dark" : "openhive-light";
|
||||
if (!_themeRegistered[theme]) {
|
||||
echarts.registerTheme(themeName, buildOpenHiveTheme(theme));
|
||||
_themeRegistered[theme] = true;
|
||||
}
|
||||
// Coerce string specs to objects (defensive — the agent should
|
||||
// pass dicts but LLMs sometimes serialize before sending).
|
||||
let parsedSpec: Record<string, unknown>;
|
||||
if (typeof spec === "string") {
|
||||
try {
|
||||
parsedSpec = JSON.parse(spec);
|
||||
} catch {
|
||||
throw new Error("spec is a string and not valid JSON");
|
||||
}
|
||||
} else {
|
||||
parsedSpec = spec as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Disjoint-region layout policy. ECharts has no auto-layout
|
||||
// for component overlap (verified against the option ref):
|
||||
// title/legend/grid are absolutely positioned and ignore each
|
||||
// other. We enforce three non-overlapping regions:
|
||||
// - Title: anchored to TOP (top:16, no bottom)
|
||||
// - Legend: anchored to BOTTOM (bottom:16, no top) except
|
||||
// when orient:'vertical' (side legend stays where placed)
|
||||
// - Grid: middle, with containLabel for axis labels
|
||||
// Strips user-supplied vertical positions so an agent spec
|
||||
// like `legend.top:"8%"` (which lands inside the title at
|
||||
// chat-bubble dimensions — the 2026-05-01 bug) can't collide.
|
||||
// Horizontal anchoring is preserved so left-aligned legends
|
||||
// still work. Must mirror chart_tools/renderer.py exactly so
|
||||
// the live chart and downloaded PNG look the same.
|
||||
const userTitle = (parsedSpec.title as Record<string, unknown> | undefined) ?? {};
|
||||
const userLegend = parsedSpec.legend as Record<string, unknown> | undefined;
|
||||
const userGrid = (parsedSpec.grid as Record<string, unknown> | undefined) ?? {};
|
||||
const legendVertical = userLegend?.orient === "vertical";
|
||||
const stripV = (o: Record<string, unknown>) => {
|
||||
const c = { ...o };
|
||||
delete c.top;
|
||||
delete c.bottom;
|
||||
return c;
|
||||
};
|
||||
const normalizedSpec: Record<string, unknown> = {
|
||||
...parsedSpec,
|
||||
title: { left: "center", ...stripV(userTitle), top: 16 },
|
||||
grid: {
|
||||
left: 56,
|
||||
right: 56,
|
||||
...stripV(userGrid),
|
||||
// Force vertical bounds — user-supplied grid.top/bottom
|
||||
// (often percentage strings like "8%" the agent picks at
|
||||
// default dims) don't generalize across chat-bubble sizes.
|
||||
// 96 covers: bottom legend (~36) + xAxis name (containLabel
|
||||
// handles tick labels but NOT axis name; outerBoundsMode is
|
||||
// v6+ and we're on v5). 40 when no legend.
|
||||
top: 64,
|
||||
bottom: userLegend && !legendVertical ? 96 : 40,
|
||||
containLabel: true,
|
||||
},
|
||||
};
|
||||
if (userLegend) {
|
||||
const legendDefaults = {
|
||||
icon: "roundRect",
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
itemGap: 16,
|
||||
};
|
||||
normalizedSpec.legend = legendVertical
|
||||
? { ...legendDefaults, ...userLegend }
|
||||
: { ...legendDefaults, ...stripV(userLegend), bottom: 16 };
|
||||
}
|
||||
|
||||
// Fresh chart instance per spec; cheaper than reuse + setOption
|
||||
// for our sizes and avoids stale state between specs.
|
||||
const chart = echarts.init(containerRef.current, themeName, {
|
||||
renderer: "svg",
|
||||
});
|
||||
chartRef.current = chart;
|
||||
chart.setOption(normalizedSpec, {
|
||||
notMerge: true,
|
||||
lazyUpdate: false,
|
||||
});
|
||||
|
||||
// Resize on container size change.
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (chartRef.current && containerRef.current) {
|
||||
(chartRef.current as { resize: () => void }).resize();
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
} catch (e) {
|
||||
if (!disposed) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
if (chartRef.current) {
|
||||
try {
|
||||
(chartRef.current as { dispose: () => void }).dispose();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [spec, theme]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] text-muted-foreground px-2.5 py-1.5 rounded-md border border-border/40 bg-muted/30"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
||||
<span>chart spec invalid: {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
// Transparent background so the chart blends with the chat bubble
|
||||
// instead of sitting in an obtrusive white card. The OpenHive
|
||||
// ECharts theme also sets backgroundColor: 'transparent' so the
|
||||
// chart itself is see-through. Subtle rounded corners only.
|
||||
className="w-full rounded-lg bg-transparent"
|
||||
style={{
|
||||
// Reserve aspect-ratio space so the chart doesn't pop in.
|
||||
// ECharts will overwrite the inline style as it lays out.
|
||||
aspectRatio,
|
||||
maxHeight,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Live Mermaid renderer for the chat bubble.
|
||||
*
|
||||
* Renders a mermaid source string to an inline SVG. Mermaid is
|
||||
* lazy-loaded so non-diagram messages don't pay the ~600 KB cost.
|
||||
*
|
||||
* Theme follows the OpenHive light/dark setting. Errors render a
|
||||
* tiny pill so the agent gets feedback for the next turn.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
source: string;
|
||||
theme?: "light" | "dark";
|
||||
}
|
||||
|
||||
let _mermaidInitialized = false;
|
||||
|
||||
export default function MermaidBlock({ source, theme = "light" }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const mermaid = (await import("mermaid")).default;
|
||||
if (!_mermaidInitialized) {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme === "dark" ? "dark" : "default",
|
||||
securityLevel: "loose",
|
||||
fontFamily:
|
||||
"'Inter Tight', -apple-system, BlinkMacSystemFont, system-ui, sans-serif",
|
||||
});
|
||||
_mermaidInitialized = true;
|
||||
}
|
||||
if (disposed || !ref.current) return;
|
||||
|
||||
// Unique id per render to avoid conflicting injected styles.
|
||||
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const { svg } = await mermaid.render(id, source);
|
||||
if (disposed || !ref.current) return;
|
||||
ref.current.innerHTML = svg;
|
||||
} catch (e) {
|
||||
if (!disposed) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [source, theme]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] text-muted-foreground px-2.5 py-1.5 rounded-md border border-border/40 bg-muted/30"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
||||
<span>diagram syntax invalid: {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
// Match EChartsBlock: transparent so the diagram blends with the
|
||||
// chat bubble; rounded corners and inner padding give breathing
|
||||
// room without adding a visible card.
|
||||
className="w-full overflow-x-auto rounded-lg bg-transparent p-4 [&_svg]:max-w-full [&_svg]:h-auto [&_svg]:mx-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* OpenHive ECharts theme — must stay in sync with
|
||||
* tools/src/chart_tools/theme.py on the runtime side.
|
||||
*
|
||||
* Same palette + spacing both for the live in-chat ECharts mount
|
||||
* (see EChartsBlock.tsx) and the headless server-side render that
|
||||
* produces the downloadable PNG. Without this both diverge: the chat
|
||||
* shows ECharts default colors and the PNG shows OpenHive colors,
|
||||
* confusing the user.
|
||||
*/
|
||||
|
||||
const PALETTE_LIGHT = [
|
||||
"#db6f02", // honey orange (primary)
|
||||
"#456a8d", // slate blue
|
||||
"#3d7a4a", // sage green
|
||||
"#a8453d", // terracotta brick
|
||||
"#c48820", // warm bronze
|
||||
"#5d5b88", // indigo
|
||||
"#7d6b51", // olive
|
||||
"#8e4200", // rust
|
||||
];
|
||||
|
||||
const PALETTE_DARK = [
|
||||
"#ffb825",
|
||||
"#7ba2c4",
|
||||
"#7bb285",
|
||||
"#d97470",
|
||||
"#e0a83a",
|
||||
"#9892c4",
|
||||
"#b8a685",
|
||||
"#d97e3a",
|
||||
];
|
||||
|
||||
export function buildOpenHiveTheme(theme: "light" | "dark" = "light") {
|
||||
const isDark = theme === "dark";
|
||||
const fg = isDark ? "#e8e6e0" : "#1a1a1a";
|
||||
const fgMuted = isDark ? "#8a8a8a" : "#6b6b6b";
|
||||
const gridLine = isDark ? "#2a2724" : "#ebe9e2";
|
||||
const axisLine = isDark ? "#3a3733" : "#d0cfca";
|
||||
const tooltipBg = isDark ? "#181715" : "#ffffff";
|
||||
const tooltipBorder = isDark ? "#2a2724" : "#d0cfca";
|
||||
const palette = isDark ? PALETTE_DARK : PALETTE_LIGHT;
|
||||
|
||||
return {
|
||||
color: palette,
|
||||
backgroundColor: "transparent",
|
||||
textStyle: {
|
||||
fontFamily:
|
||||
'"Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
color: fg,
|
||||
fontSize: 12,
|
||||
},
|
||||
title: {
|
||||
left: "center",
|
||||
top: 28,
|
||||
textStyle: { color: fg, fontSize: 16, fontWeight: 600 },
|
||||
subtextStyle: { color: fgMuted, fontSize: 12 },
|
||||
},
|
||||
legend: {
|
||||
top: 64,
|
||||
icon: "roundRect",
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
itemGap: 20,
|
||||
textStyle: { color: fgMuted, fontSize: 12 },
|
||||
},
|
||||
grid: {
|
||||
top: 116,
|
||||
left: 48,
|
||||
right: 48,
|
||||
bottom: 72,
|
||||
containLabel: true,
|
||||
},
|
||||
categoryAxis: {
|
||||
axisLine: { show: true, lineStyle: { color: axisLine } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: fgMuted, fontSize: 11, margin: 14 },
|
||||
splitLine: { show: false },
|
||||
nameLocation: "middle",
|
||||
nameGap: 36,
|
||||
nameTextStyle: { color: fgMuted, fontSize: 12 },
|
||||
},
|
||||
valueAxis: {
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: fgMuted, fontSize: 11, margin: 14 },
|
||||
splitLine: { lineStyle: { color: gridLine, type: "dashed" } },
|
||||
nameLocation: "middle",
|
||||
nameGap: 42,
|
||||
nameTextStyle: { color: fgMuted, fontSize: 12, fontWeight: 500 },
|
||||
// Don't auto-rotate value-axis names — the theme can't tell xAxis
|
||||
// (horizontal bar) from yAxis (vertical bar), so rotating both at
|
||||
// 90° vertical-mounts the xAxis name on horizontal-bar charts and
|
||||
// it collides with the legend (peer_val regression). Let specs
|
||||
// set nameRotate explicitly when they want a vertical y-name.
|
||||
},
|
||||
timeAxis: {
|
||||
axisLine: { show: true, lineStyle: { color: axisLine } },
|
||||
axisLabel: { color: fgMuted, fontSize: 11, margin: 14 },
|
||||
splitLine: { show: false },
|
||||
nameLocation: "middle",
|
||||
nameGap: 36,
|
||||
nameTextStyle: { color: fgMuted, fontSize: 12 },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: tooltipBg,
|
||||
borderColor: tooltipBorder,
|
||||
borderWidth: 1,
|
||||
padding: [8, 12],
|
||||
textStyle: { color: fg, fontSize: 12 },
|
||||
axisPointer: {
|
||||
lineStyle: { color: axisLine, type: "dashed" },
|
||||
crossStyle: { color: axisLine },
|
||||
},
|
||||
},
|
||||
bar: { itemStyle: { borderRadius: [3, 3, 0, 0] } },
|
||||
line: {
|
||||
lineStyle: { width: 2.5 },
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
},
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: "#3d7a4a",
|
||||
color0: "#a8453d",
|
||||
borderColor: "#3d7a4a",
|
||||
borderColor0: "#a8453d",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -295,12 +295,40 @@ export function sseEventToChatMessage(
|
||||
* deferred `tool_call_completed` events can find the exact pill they belong
|
||||
* to after the turn counter moves on.
|
||||
*/
|
||||
/**
|
||||
* For chart_* tools we retain the args (from tool_call_started) and
|
||||
* result envelope (from tool_call_completed) so the chat panel can
|
||||
* render the live chart inline from the same spec the runtime
|
||||
* rasterized to PNG. Other tools omit these fields to keep the
|
||||
* tool_status content payload small (catalogs are pill-only).
|
||||
*/
|
||||
type ToolEntry = {
|
||||
name: string;
|
||||
done: boolean;
|
||||
/** opaque per-call id surfaced to the UI; used to key React rows */
|
||||
callKey?: string;
|
||||
/** present only for tools whose name matches shouldRetainDetail */
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type ToolRowState = {
|
||||
streamId: string;
|
||||
executionId: string;
|
||||
tools: Record<string, { name: string; done: boolean }>;
|
||||
tools: Record<string, ToolEntry>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Names whose detail (args + result envelope) we surface in the chat.
|
||||
* Other tools stay pill-only — keeping their args/results out of the
|
||||
* message content avoids ballooning the chat history with tool
|
||||
* catalogs, file blobs, etc.
|
||||
*/
|
||||
function shouldRetainDetail(toolName: string): boolean {
|
||||
return toolName.startsWith("chart_");
|
||||
}
|
||||
|
||||
export interface ReplayState {
|
||||
turnCounters: Record<string, number>;
|
||||
toolRows: Record<string, ToolRowState>;
|
||||
@@ -349,10 +377,20 @@ function toolLookupKey(
|
||||
}
|
||||
|
||||
function toolRowContent(row: ToolRowState): string {
|
||||
const tools = Object.values(row.tools).map((t) => ({
|
||||
name: t.name,
|
||||
done: t.done,
|
||||
}));
|
||||
const tools = Object.values(row.tools).map((t) => {
|
||||
const out: ToolEntry = { name: t.name, done: t.done };
|
||||
// Carry callKey + retained fields only for tools whose detail the
|
||||
// UI mounts (chart_*). Pill-only tools stay terse so the
|
||||
// tool_status payload doesn't grow with every catalog/file_ops
|
||||
// call and existing snapshot tests stay valid.
|
||||
if (shouldRetainDetail(t.name)) {
|
||||
if (t.callKey !== undefined) out.callKey = t.callKey;
|
||||
if (t.args !== undefined) out.args = t.args;
|
||||
if (t.result !== undefined) out.result = t.result;
|
||||
if (t.isError !== undefined) out.isError = t.isError;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const allDone = tools.length > 0 && tools.every((t) => t.done);
|
||||
return JSON.stringify({ tools, allDone });
|
||||
}
|
||||
@@ -417,10 +455,19 @@ export function replayEvent(
|
||||
tools: {},
|
||||
});
|
||||
const toolKey = toolUseId || `anonymous-${Object.keys(row.tools).length}`;
|
||||
row.tools[toolKey] = {
|
||||
const entry: ToolEntry = {
|
||||
name: toolName,
|
||||
done: false,
|
||||
callKey: toolKey,
|
||||
};
|
||||
// Capture args at start for retained-detail tools so the chat
|
||||
// can show what the agent rendered. Other tools' arguments are
|
||||
// intentionally dropped to keep the tool_status JSON small.
|
||||
if (shouldRetainDetail(toolName)) {
|
||||
const toolInput = event.data?.tool_input;
|
||||
if (toolInput !== undefined) entry.args = toolInput;
|
||||
}
|
||||
row.tools[toolKey] = entry;
|
||||
if (toolUseId) {
|
||||
state.toolUseToPill[toolLookupKey(streamId, event.execution_id, toolUseId)] = {
|
||||
msgId: pillId,
|
||||
@@ -453,10 +500,38 @@ export function replayEvent(
|
||||
if (!tracked) break;
|
||||
const row = state.toolRows[tracked.msgId];
|
||||
if (!row) break;
|
||||
row.tools[tracked.toolKey] = {
|
||||
name: row.tools[tracked.toolKey]?.name || tracked.name,
|
||||
const prior = row.tools[tracked.toolKey];
|
||||
const completedName = prior?.name || tracked.name;
|
||||
const completed: ToolEntry = {
|
||||
name: completedName,
|
||||
done: true,
|
||||
callKey: tracked.toolKey,
|
||||
};
|
||||
// Preserve any args captured at start; capture the result
|
||||
// envelope for retained-detail tools (chart_* needs spec/file_url
|
||||
// to mount the live chart).
|
||||
if (shouldRetainDetail(completedName)) {
|
||||
if (prior?.args !== undefined) completed.args = prior.args;
|
||||
const rawResult = event.data?.result;
|
||||
if (rawResult !== undefined) {
|
||||
// The framework serializes envelopes as JSON strings. Try to
|
||||
// parse so the renderer can pick fields cheaply; fall back to
|
||||
// the raw value when parsing fails (already-an-object or
|
||||
// non-JSON string).
|
||||
if (typeof rawResult === "string") {
|
||||
try {
|
||||
completed.result = JSON.parse(rawResult);
|
||||
} catch {
|
||||
completed.result = rawResult;
|
||||
}
|
||||
} else {
|
||||
completed.result = rawResult;
|
||||
}
|
||||
}
|
||||
const isErr = event.data?.is_error;
|
||||
if (typeof isErr === "boolean") completed.isError = isErr;
|
||||
}
|
||||
row.tools[tracked.toolKey] = completed;
|
||||
out.push({
|
||||
id: tracked.msgId,
|
||||
agent: effectiveName || event.node_id || "Agent",
|
||||
|
||||
Reference in New Issue
Block a user