Merge pull request #12179 from esengine/feat/chart-desktop-renderer

feat(desktop): draw render_chart calls as inline SVG charts in the transcript
This commit is contained in:
YHH authored and GitHub committed 2026-10-06 00:41:01 -07:00
commit 444ca4fa45
21 files changed
+1186 -4

No files matched your search

+2
View File
@@ -52,6 +52,8 @@ export const ACTIONS: UIAction[] = [
// Completion verification is adaptive now, rather than a user-facing preset.
// The legacy endpoint remains in the port for older clients and sessions,
// but it is intentionally not advertised as an interface action.
{ id: "chart.scroll", kind: "view", target: "none", proof: "interaction" },
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
+11
View File
@@ -1318,4 +1318,15 @@ export const EN: Record<string, string> = {
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
"上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。": "Context at {percent}% of the compaction threshold — the model was told it has about {remaining} tokens of room left.",
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
"图表": "Chart",
"图表:{title}(可横向滚动)": "Chart: {title} (scrolls sideways)",
"没有可画的数据": "Nothing to draw",
"柱状图": "Bar chart",
"折线图": "Line chart",
"饼图": "Pie chart",
"{kind}:{title},共 {n} 行": "{kind}: {title}, {n} rows",
"查看数据": "View data",
"收起数据": "Hide data",
"图表数据:{title}": "Chart data: {title}",
"另有 {n} 行": "and {n} more rows",
};
+3
View File
@@ -31,6 +31,9 @@ export const count = (n: number) => nf({ maximumFractionDigits: 0 }).format(n);
export const decimals = (v: number, digits: number) =>
nf({ minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v);
/** plain is a number kept to at most n places, with none padded on. */
export const plain = (v: number, digits: number) => nf({ maximumFractionDigits: digits }).format(v);
/** tokens abbreviates a count of tokens or characters. Under ten thousand it
* keeps one decimal and above it drops to none, so the field's width stays put
* as a turn grows. */
+1
View File
@@ -8,6 +8,7 @@ import "./styles/app.css";
import "./styles/studio.css";
import "./styles/feedback.css";
import "./styles/community.css";
import "./styles/chart.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
@@ -127,6 +127,11 @@ grid to break.
lightness band, chroma floor, adjacent-pair colour-vision separation
(ΔE ≥ 8), normal-vision floor (≥ 15), and contrast with the ground.
- In light, four segments fall below 3:1 and rely on the legend's text labels.
- Chart series take `--cat-1` … `--cat-5` by index. A sixth series onward
repeats the ramp with a second channel (dash for lines, hatch for bars,
lower opacity for slices) because five distinguishable hues is the ceiling,
and every chart carries a legend or a data table so colour is never the only
way to read a series.
- `--lights-w` / `--lights-h` are the window corner macOS draws its controls
over, and they are zero everywhere the shell does not hand that corner to the
page. Whichever element occupies the corner reserves them, so a layout change
@@ -0,0 +1,54 @@
.call[data-k="chart"] > .g .sym { color: var(--cat-1) }
.chart { margin: 6px 0 0; max-width: 720px }
.chart-defs { position: absolute; width: 0; height: 0 }
.chart-hold { height: 300px; max-width: 720px }
.chart-scroll { position: relative }
.chart-scroll::before, .chart-scroll::after { content: ""; position: absolute; top: 0; bottom: 0; width: 14px; pointer-events: none; opacity: 0; z-index: 1 }
.chart-scroll::before { left: 0; background: linear-gradient(to right, var(--border), transparent) }
.chart-scroll::after { right: 0; background: linear-gradient(to left, var(--border), transparent) }
.chart-scroll[data-more-left]::before, .chart-scroll[data-more-right]::after { opacity: 1 }
.chart-pane { overflow-x: auto }
.chart-pane:focus-visible { outline: 2px solid var(--text); outline-offset: 2px }
.chart-mark + .chart-mark { margin-top: 14px }
.chart-svg { display: block; width: 100%; height: auto; overflow: visible }
.chart-svg.pie { max-width: 320px }
.chart .grid { stroke: var(--hair); stroke-width: 1 }
.chart .base { stroke: var(--border); stroke-width: 1 }
.chart .tick { fill: var(--faint); font: var(--w-reg) var(--fs-micro) var(--ui) }
.chart .cs0 { --c: var(--cat-1) }
.chart .cs1 { --c: var(--cat-2) }
.chart .cs2 { --c: var(--cat-3) }
.chart .cs3 { --c: var(--cat-4) }
.chart .cs4 { --c: var(--cat-5) }
.chart .bar rect:first-child { fill: var(--c) }
.chart .hatch-line { stroke: var(--surface); stroke-width: 2; opacity: .7 }
.chart .line path { fill: none; stroke: var(--c); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round }
.chart .line.sv1 path { stroke-dasharray: 7 4 }
.chart .line.sv2 path { stroke-dasharray: 2 4 }
.chart .line .pt { fill: var(--surface); stroke: var(--c); stroke-width: 2 }
.chart .slice { fill: var(--c); stroke: var(--surface); stroke-width: 2 }
.chart .slice.sv1 { fill-opacity: .72 }
.chart .slice.sv2 { fill-opacity: .5 }
.chart [tabindex]:focus-visible { outline: 2px solid var(--text); outline-offset: 2px }
.chart .bar:hover rect:first-child, .chart .slice:hover { filter: brightness(1.08) }
.chart-key { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0 0; padding: 0; list-style: none; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
.chart-key li { display: inline-flex; align-items: center; gap: 6px; min-width: 0 }
.chart-key .nm { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.chart-key .v { color: var(--ghost); font-variant-numeric: tabular-nums }
.chart-key i { flex: none; width: 10px; height: 10px; border-radius: 2px; background: var(--c) }
.chart-key i.sv1 { background: repeating-linear-gradient(45deg, var(--c) 0 3px, transparent 3px 5px) }
.chart-key i.sv2 { background: repeating-linear-gradient(45deg, var(--c) 0 1.5px, transparent 1.5px 4px) }
.chart-pie { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px }
.chart-pie .chart-key { flex-direction: column; flex-wrap: nowrap; margin: 0 }
.chart-axes { margin-top: 4px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
.chart-read { min-height: 1.2em; margin-top: 2px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--text); font-variant-numeric: tabular-nums }
.chart-data { margin-top: 0 }
.chart-toggle { padding: 2px 0; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); background: none; border: 0; cursor: pointer }
.chart-toggle:hover { color: var(--text) }
.chart-table { max-height: 260px; max-width: 720px; overflow: auto; margin-top: 4px; border: 1px solid var(--hair); border-radius: var(--r-sm) }
.chart-table table { width: 100%; border-collapse: collapse; font: var(--w-reg) var(--fs-meta) var(--ui) }
.chart-table th, .chart-table td { padding: 4px 10px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--hair) }
.chart-table th { position: sticky; top: 0; background: var(--surface); color: var(--faint); font-weight: var(--w-med) }
.chart-table [data-num] { text-align: right; font-variant-numeric: tabular-nums }
.chart-more { padding: 6px 10px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
.chart-empty { padding: 24px 0; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
+15 -3
View File
@@ -6,6 +6,9 @@ import type { ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope
import { RMark } from "./RMark";
import { Await } from "./Await";
import { ToolCard } from "./cards/ToolCard";
import { Boundary } from "./Boundary";
import { LazyChart } from "./chart/LazyChart";
import { chartOfTool } from "./chart/spec";
import { GuardianCard } from "./cards/GuardianCard";
import { ApprovalCard, type PlanAction } from "./cards/ApprovalCard";
import { AskCard } from "./cards/AskCard";
@@ -680,8 +683,10 @@ const Row = memo(function Row({
// The ask tool also raises ask_request, which carries the id /answer
// needs. Drawing the tool call too put two copies of the same question on
// screen, each answerable.
return it.tool.name === "ask" ? null : (
<ToolCard
if (it.tool.name === "ask") return null;
return (() => {
const card = (
<ToolCard
tool={it.tool}
running={it.running}
activity={activity}
@@ -691,7 +696,14 @@ const Row = memo(function Row({
onPrepareFileRevert={onPrepareFileRevert}
onCommitFileRevert={onCommitFileRevert}
/>
);
);
const chart = activity ? null : chartOfTool(it.tool);
return chart ? (
<Boundary fallback={card} retryKey={it.tool.args}>
<LazyChart spec={chart} callId={it.tool.id} />
</Boundary>
) : card;
})();
case "reads":
return <ReadsCard tools={it.tools} />;
case "guardian":
@@ -0,0 +1,168 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../testkit";
import { ChartCard } from "./ChartCard";
import { SALES, withSpec } from "./fixtures";
import type { ChartSpec } from "./spec";
afterEach(cleanup);
const draw = (spec: ChartSpec) => render(<ChartCard spec={spec} callId="call-1" />).container;
const series = (): ChartSpec => withSpec({
data: { columns: [{ name: "d", type: "date" }, { name: "v", type: "number" }], rows: [["2024-01-01", 1], ["2024-02-01", null], ["2024-03-01", 3], ["2024-04-01", 4]] },
marks: [{ type: "line", x: "d", y: ["v"] }],
});
describe("a chart card", () => {
it("names itself, its source call and its content for assistive tech", () => {
const box = draw(SALES);
expect(box.querySelector(".call")?.getAttribute("data-call")).toBe("call-1");
expect(box.querySelector(".hl .nm")?.textContent).toBe("图表");
const label = box.querySelector("figure")?.getAttribute("aria-label") ?? "";
expect(label).toContain("Sales");
expect(label).toContain("3");
});
it("draws one bar per row, each reachable by keyboard", async () => {
const box = draw(SALES);
const bars = box.querySelectorAll(".bar");
expect(bars).toHaveLength(3);
await userEvent.tab();
await userEvent.tab();
expect(document.activeElement?.getAttribute("aria-label")).toBe("revenue · Jan · 10");
expect(box.querySelector(".chart-read")?.textContent).toBe("revenue · Jan · 10");
});
it("never hides a focusable point from assistive tech and announces the readout", () => {
for (const spec of [SALES, series(), withSpec({ marks: [{ type: "pie", x: "month", y: ["revenue"] }] })]) {
const box = draw(spec);
expect(box.querySelectorAll("[tabindex]").length).toBeGreaterThan(0);
expect(box.querySelectorAll('[aria-hidden="true"] [tabindex], [aria-hidden="true"][tabindex]')).toHaveLength(0);
expect(box.querySelector(".chart-read")?.getAttribute("aria-live")).toBe("polite");
cleanup();
}
});
it("says so when a pie has nothing to draw", () => {
const box = draw(withSpec({ data: { ...SALES.data, rows: [["a", 0], ["b", null]] }, marks: [{ type: "pie", x: "month", y: ["revenue"] }] }));
expect(box.querySelector(".chart-empty")?.textContent).toBe("没有可画的数据");
});
it("keeps a plot at its drawn size and lets it scroll by keyboard instead of shrinking", () => {
const box = draw(SALES);
const mark = box.querySelector(".chart-pane")!;
expect(mark.getAttribute("tabindex")).toBe("0");
expect(mark.getAttribute("role")).toBe("region");
expect(mark.getAttribute("aria-label")).toBe("图表:Sales(可横向滚动)");
expect((box.querySelector(".chart-svg") as SVGElement).style.minWidth).toBe("640px");
});
it("shows when there is more to scroll to, and from which side", () => {
const size = (w: number, c: number) => {
Object.defineProperty(HTMLElement.prototype, "scrollWidth", { configurable: true, get: () => w });
Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, get: () => c });
};
size(640, 320);
const box = draw(SALES);
const wrap = box.querySelector(".chart-scroll")!;
expect(wrap.hasAttribute("data-more-right")).toBe(true);
expect(wrap.hasAttribute("data-more-left")).toBe(false);
const mark = box.querySelector(".chart-pane") as HTMLElement;
mark.scrollLeft = 320;
fireEvent.scroll(mark);
expect(wrap.hasAttribute("data-more-left")).toBe(true);
expect(wrap.hasAttribute("data-more-right")).toBe(false);
cleanup();
size(640, 640);
expect(draw(SALES).querySelector(".chart-scroll")!.hasAttribute("data-more-right")).toBe(false);
delete (HTMLElement.prototype as unknown as Record<string, unknown>).scrollWidth;
delete (HTMLElement.prototype as unknown as Record<string, unknown>).clientWidth;
});
it("breaks a line at a null instead of bridging it", () => {
const box = draw(series());
expect(box.querySelectorAll(".line path")).toHaveLength(2);
});
it("shows the data as a real table on request and hides it again", async () => {
const box = draw(SALES);
expect(box.querySelector("table")).toBeNull();
const toggle = screen.getByRole("button", { name: "查看数据" });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
await userEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect([...box.querySelectorAll("th")].map((h) => h.textContent)).toEqual(["month", "revenue"]);
expect(box.querySelectorAll("tbody tr")).toHaveLength(3);
await userEvent.click(screen.getByRole("button", { name: "收起数据" }));
expect(box.querySelector("table")).toBeNull();
});
it("caps the table and says how many rows it left out", async () => {
const rows = Array.from({ length: 250 }, (_, i) => [`c${i}`, i]);
const box = draw(withSpec({ data: { ...SALES.data, rows } }));
await userEvent.click(screen.getByRole("button", { name: "查看数据" }));
expect(box.querySelectorAll("tbody tr")).toHaveLength(200);
expect(box.querySelector(".chart-more")?.textContent).toBe("另有 50 行");
});
it("draws hostile strings as inert text", async () => {
const evil = '<img src=x onerror=alert(1)><script>alert(1)</script>';
const box = draw(withSpec({
title: evil,
data: { columns: [{ name: "m", type: "string" }, { name: "v", type: "number" }], rows: [[evil, 1]] },
marks: [{ type: "bar", x: "m", y: ["v"] }],
y_axis: { title: evil, unit: evil },
}));
await userEvent.click(screen.getByRole("button", { name: "查看数据" }));
expect(box.querySelector("script, img, [onerror]")).toBeNull();
expect(box.textContent).toContain("<script>");
});
it("draws a pie's slices with their share and a donut with a hole", () => {
const pie = (donut: boolean) => draw(withSpec({ marks: [{ type: "pie", x: "month", y: ["revenue"], donut }] }));
const solid = pie(false);
expect(solid.querySelectorAll(".slice")).toHaveLength(3);
expect(solid.querySelector(".chart-key .v")?.textContent).toBe("16.7%");
cleanup();
expect(pie(true).querySelector(".slice")?.getAttribute("d")).not.toBe(solid.querySelector(".slice")?.getAttribute("d"));
});
it("gives every series a name in the legend and a second channel past the fifth", () => {
const groups = Array.from({ length: 7 }, (_, i) => `g${i}`);
const rows = groups.map((g) => ["a", g, 1]);
const box = draw(withSpec({
data: { columns: [{ name: "x", type: "string" }, { name: "g", type: "string" }, { name: "v", type: "number" }], rows },
marks: [{ type: "line", x: "x", y: ["v"], color: "g" }],
}));
expect(box.querySelectorAll(".chart-key li")).toHaveLength(7);
expect(box.querySelectorAll(".line.sv1")).toHaveLength(2);
});
it("hatches bars from the sixth series on", () => {
const groups = Array.from({ length: 7 }, (_, i) => `g${i}`);
const box = draw(withSpec({
data: { columns: [{ name: "x", type: "string" }, { name: "g", type: "string" }, { name: "v", type: "number" }], rows: groups.map((g) => ["a", g, 1]) },
marks: [{ type: "bar", x: "x", y: ["v"], color: "g" }],
}));
expect(box.querySelectorAll(".bar .hatch")).toHaveLength(2);
});
it("stacks bars and keeps a spec with several marks as several plots", () => {
const box = draw(withSpec({ marks: [{ type: "bar", x: "month", y: ["revenue"], stacked: true }, { type: "line", x: "month", y: ["revenue"] }] }));
expect(box.querySelectorAll(".chart-mark")).toHaveLength(2);
});
it("carries no animation of its own", () => {
const box = draw(SALES);
expect(box.querySelector("animate, animateTransform, [style*='animation']")).toBeNull();
});
it("handles an empty data set and an all-zero pie without throwing", () => {
expect(() => draw(withSpec({ data: { ...SALES.data, rows: [] } }))).not.toThrow();
cleanup();
expect(() => draw(withSpec({ data: { ...SALES.data, rows: [["a", 0]] }, marks: [{ type: "pie", x: "month", y: ["revenue"] }] }))).not.toThrow();
});
});
@@ -0,0 +1,113 @@
import { memo, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { t } from "../../i18n";
import { Sym } from "../Sym";
import { plotOf } from "./model";
import { ChartTable } from "./ChartTable";
import { BarPlot, LinePlot, PiePlot } from "./plots";
import { clip } from "./format";
import type { ChartMark, ChartSpec } from "./spec";
function describe(spec: ChartSpec): string {
const kinds = spec.marks.map((m) => (m.type === "bar" ? t("柱状图") : m.type === "line" ? t("折线图") : t("饼图")));
return t("{kind}:{title},共 {n} 行", { kind: [...new Set(kinds)].join(" · "), title: spec.title, n: spec.data.rows.length });
}
function axes(spec: ChartSpec): string {
const one = (a?: { title?: string; unit?: string }) => [a?.title, a?.unit && `(${a.unit})`].filter(Boolean).join(" ");
return [one(spec.x_axis), one(spec.y_axis)].filter(Boolean).join(" × ");
}
function Legend({ spec, mark }: { spec: ChartSpec; mark: ChartMark }) {
if (mark.type === "pie") return null;
const names = plotOf(spec, mark).series.map((s) => s.label);
if (names.length < 2) return null;
return (
<ul className="chart-key">
{names.map((n, i) => (
<li key={n}>
<i className={`cs${i % 5} sv${Math.min(Math.floor(i / 5), 2)}`} />
<span className="nm">{clip(n, 28)}</span>
</li>
))}
</ul>
);
}
const Mark = memo(function Mark({ spec, mark, hot, hatch }: { spec: ChartSpec; mark: ChartMark; hot: (s: string | null) => void; hatch: string }) {
const frame = { spec, mark, hot, hatch };
return mark.type === "bar" ? <BarPlot {...frame} /> : mark.type === "line" ? <LinePlot {...frame} /> : <PiePlot {...frame} />;
});
function Scroller({ scrolls, label, children }: { scrolls: boolean; label: string; children: ReactNode }) {
const box = useRef<HTMLDivElement>(null);
const [more, setMore] = useState({ left: false, right: false });
const measure = useCallback(() => {
const el = box.current;
if (!el) return;
const next = { left: el.scrollLeft > 1, right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1 };
setMore((m) => (m.left === next.left && m.right === next.right ? m : next));
}, []);
useEffect(() => {
measure();
const el = box.current;
if (!el || !scrolls) return;
const watch = new ResizeObserver(measure);
watch.observe(el);
return () => watch.disconnect();
}, [measure, scrolls]);
return (
<div className="chart-scroll" data-more-left={more.left || undefined} data-more-right={more.right || undefined}>
<div
className="chart-pane"
ref={box}
onScroll={measure}
data-action={scrolls ? "chart.scroll" : undefined}
tabIndex={scrolls ? 0 : undefined}
role={scrolls ? "region" : undefined}
aria-label={scrolls ? label : undefined}
>
{children}
</div>
</div>
);
}
export function ChartCard({ spec, callId }: { spec: ChartSpec; callId?: string }) {
const uid = useId();
const [read, setRead] = useState<string | null>(null);
const hatch = `${uid}-hatch`;
return (
<div className="call" data-k="chart" data-call={callId || undefined}>
<div className="g">
<Sym glyph="⌗" />
<span className="line" />
</div>
<div className="c">
<div className="hl">
<span className="nm">{t("图表")}</span>
<span className="arg">{spec.title}</span>
</div>
<figure className="chart" role="group" aria-label={describe(spec)}>
<svg width="0" height="0" aria-hidden="true" className="chart-defs">
<defs>
<pattern id={hatch} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="6" className="hatch-line" />
</pattern>
</defs>
</svg>
{spec.marks.map((m, i) => (
<div className="chart-mark" key={i}>
<Scroller scrolls={m.type !== "pie"} label={t("图表:{title}(可横向滚动)", { title: spec.title })}>
<Mark spec={spec} mark={m} hot={setRead} hatch={hatch} />
</Scroller>
<Legend spec={spec} mark={m} />
</div>
))}
{axes(spec) && <figcaption className="chart-axes">{axes(spec)}</figcaption>}
<div className="chart-read" aria-live="polite">{read ?? " "}</div>
</figure>
<ChartTable spec={spec} id={`${uid}-data`} />
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { useState } from "react";
import { t } from "../../i18n";
import type { ChartSpec } from "./spec";
import { cellText } from "./format";
const SHOWN = 200;
export function ChartTable({ spec, id }: { spec: ChartSpec; id: string }) {
const [open, setOpen] = useState(false);
const rows = spec.data.rows.slice(0, SHOWN);
const rest = spec.data.rows.length - rows.length;
return (
<div className="chart-data">
<button type="button" className="chart-toggle" data-action="chart.data" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)}>
{open ? t("收起数据") : t("查看数据")}
</button>
{open && (
<div className="chart-table" id={id} tabIndex={0} role="region" aria-label={t("图表数据:{title}", { title: spec.title })}>
<table>
<thead>
<tr>{spec.data.columns.map((c) => <th key={c.name} scope="col" data-num={c.type === "number" || undefined}>{c.name}</th>)}</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>{r.map((v, c) => <td key={c} data-num={spec.data.columns[c].type === "number" || undefined}>{cellText(v)}</td>)}</tr>
))}
</tbody>
</table>
{rest > 0 && <div className="chart-more">{t("另有 {n} 行", { n: rest })}</div>}
</div>
)}
</div>
);
}
@@ -0,0 +1,23 @@
import { lazy, Suspense } from "react";
import type { ChartSpec } from "./spec";
function loader() {
return lazy(async () => {
try {
return { default: (await import("./ChartCard")).ChartCard };
} catch (err) {
Chart = loader();
throw err;
}
});
}
let Chart = loader();
export function LazyChart({ spec, callId }: { spec: ChartSpec; callId?: string }) {
return (
<Suspense fallback={<div className="chart-hold" />}>
<Chart spec={spec} callId={callId} />
</Suspense>
);
}
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "../testkit";
import { Transcript } from "../Transcript";
import { fromHistory, type Item } from "../../state/session";
import type { HistoryMessage } from "../../port/session";
import { SALES, chartCall } from "./fixtures";
afterEach(cleanup);
const noop = async () => undefined as never;
function draw(items: Item[]) {
return render(
<Transcript items={items} entering={[]} onEntered={() => {}} revision={1} waiting={{}} scroll={{ current: null }} hidden={false}
onPinned={() => {}} jump={0} focus={null} onApprove={noop} onFullAccess={noop} onPlan={noop} onAnswer={noop} onForget={noop}
onExtInvoke={() => {}} onExtSubmit={noop} checkpoints={new Map()} onPrepareRewind={noop} onCommitRewind={noop} onUndoRewind={noop}
onPrepareFileRevert={noop} onCommitFileRevert={noop} needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}} />,
).container;
}
const turn = (tool: ReturnType<typeof chartCall>): Item[] => [
{ t: "user", id: "u1", text: "chart it", authoredTurn: 1, msgIndex: 0 },
{ t: "say", id: "s1", text: "Here you go.", done: true },
{ t: "tool", id: "t1", running: false, children: [], tool },
];
const history = (spec: unknown, failed = false): HistoryMessage[] => {
const call = chartCall(spec);
return [
{ role: "user", content: "chart it", msgIndex: 0 },
{ role: "assistant", content: "", toolCalls: [{ id: "c1", name: "use_capability", arguments: call.args, resolvedName: "render_chart", capabilityId: "tool:render_chart" }] },
{ role: "tool", content: failed ? "chart.schema_invalid" : "chart_id: chart-abc", toolCallId: "c1", toolFailed: failed },
] as HistoryMessage[];
};
describe("a chart in the transcript", () => {
it("is its own row, outside the folded execution group", async () => {
const box = draw(turn(chartCall(SALES)));
expect(await screen.findByRole("group", { name: /Sales/ })).not.toBeNull();
expect(box.querySelector(".activity-group .chart")).toBeNull();
expect(box.querySelector('[data-item="t1"] .chart')).not.toBeNull();
});
it("falls back to the ordinary tool card while the call runs", () => {
const box = draw(turn(chartCall(SALES, { output: undefined })));
expect(box.querySelector(".chart")).toBeNull();
expect(box.querySelector(".activity-group")).not.toBeNull();
});
it("falls back to the ordinary tool card when the kernel refused the spec", () => {
const box = draw(turn(chartCall({ ...SALES, marks: [] }, { err: "chart.schema_invalid" })));
expect(box.querySelector(".chart")).toBeNull();
expect(box.textContent).toContain("chart.schema_invalid");
});
it("draws the same chart after a reload as it drew live", async () => {
const live = draw(turn(chartCall(SALES)));
await screen.findByRole("group", { name: /Sales/ });
const liveSvg = live.querySelector(".chart-svg")?.innerHTML;
cleanup();
const rebuilt = fromHistory(history(SALES)).items;
const box = draw(rebuilt);
await screen.findByRole("group", { name: /Sales/ });
expect(box.querySelector(".chart-svg")?.innerHTML).toBe(liveSvg);
});
it("does not draw a rebuilt call the kernel had refused, or a hostile stored spec", () => {
for (const items of [fromHistory(history(SALES, true)).items, fromHistory(history({ ...SALES, spec_version: 9 })).items]) {
const box = draw(items);
expect(box.querySelector(".chart")).toBeNull();
cleanup();
}
});
});
@@ -0,0 +1,27 @@
import type { Tool } from "../../port/wire";
import type { ChartSpec } from "./spec";
export const SALES: ChartSpec = {
spec_version: 1,
title: "Sales",
data: {
columns: [{ name: "month", type: "string" }, { name: "revenue", type: "number" }],
rows: [["Jan", 10], ["Feb", 30], ["Mar", 20]],
},
marks: [{ type: "bar", x: "month", y: ["revenue"] }],
};
export const withSpec = (over: Record<string, unknown>) => ({ ...SALES, ...over }) as unknown as ChartSpec;
export function chartCall(spec: unknown, over: Partial<Tool> = {}): Tool {
return {
id: "call-1",
name: "use_capability",
args: JSON.stringify({ action: "call", capability_id: "tool:render_chart", arguments: spec }),
resolvedName: "render_chart",
capabilityId: "tool:render_chart",
output: "chart_id: chart-abc\nchart \"Sales\", 3 rows, 2 columns",
readOnly: true,
...over,
} as Tool;
}
@@ -0,0 +1,15 @@
import { count, plain } from "../../i18n/format";
import type { Cell, ChartAxis } from "./spec";
export function formatValue(v: number, axis?: ChartAxis): string {
const body = axis?.format === "integer" ? count(v) : plain(v, 2);
if (axis?.format === "percent") return `${body}%`;
return axis?.unit ? `${body} ${axis.unit}` : body;
}
export const cellText = (c: Cell): string => (c === null ? "" : typeof c === "number" ? plain(c, 6) : c);
export function clip(s: string, max: number): string {
const chars = [...s];
return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : s;
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { logTicks, niceTicks, plotOf, slicesOf, stackOf, yScaleOf } from "./model";
import type { ChartSpec } from "./spec";
const grouped: ChartSpec = {
spec_version: 1,
title: "t",
data: {
columns: [{ name: "d", type: "date" }, { name: "g", type: "string" }, { name: "v", type: "number" }],
rows: [["2024-01-01", "a", 1], ["2024-01-01", "b", 2], ["2024-02-01", "a", null], ["2024-02-01", "b", 4]],
},
marks: [{ type: "line", x: "d", y: ["v"], color: "g" }],
};
describe("plotOf", () => {
it("splits one series per distinct color value and keeps first-seen order", () => {
const p = plotOf(grouped, grouped.marks[0]);
expect(p.categories).toEqual(["2024-01-01", "2024-02-01"]);
expect(p.series.map((s) => s.label)).toEqual(["a", "b"]);
expect(p.series[0].values).toEqual([1, null]);
expect(p.series[1].values).toEqual([2, 4]);
});
it("gives dates and numbers a position and strings none", () => {
expect(plotOf(grouped, grouped.marks[0]).positions).toEqual([Date.parse("2024-01-01"), Date.parse("2024-02-01")]);
const s: ChartSpec = { ...grouped, data: { columns: [{ name: "d", type: "string" }, { name: "v", type: "number" }], rows: [["a", 1]] }, marks: [{ type: "bar", x: "d", y: ["v"] }] };
expect(plotOf(s, s.marks[0]).positions).toBeNull();
});
it("keeps the last row of a repeated category", () => {
const s: ChartSpec = { ...grouped, data: { columns: [{ name: "d", type: "string" }, { name: "v", type: "number" }], rows: [["a", 1], ["a", 9]] }, marks: [{ type: "bar", x: "d", y: ["v"] }] };
expect(plotOf(s, s.marks[0]).series[0].values).toEqual([9]);
});
it("names a series by column and value when several columns split", () => {
const m = { ...grouped.marks[0], y: ["v", "v"] };
expect(plotOf(grouped, m).series.map((s) => s.label)).toContain("v / a");
});
});
describe("slicesOf", () => {
it("leaves out nulls and zeros", () => {
const s: ChartSpec = { ...grouped, data: { columns: [{ name: "k", type: "string" }, { name: "v", type: "number" }], rows: [["a", 3], ["b", 0], ["c", null], ["d", 1]] }, marks: [{ type: "pie", x: "k", y: ["v"] }] };
expect(slicesOf(s, s.marks[0])).toEqual([{ label: "a", value: 3 }, { label: "d", value: 1 }]);
});
});
describe("scales", () => {
it("picks round ticks that cover the data and reaches zero for bars", () => {
const y = yScaleOf([13, 87], undefined, true);
expect(y.ticks[0]).toBe(0);
expect(y.ticks[y.ticks.length - 1]).toBeGreaterThanOrEqual(87);
expect(y.at(0)).toBe(0);
expect(y.at(y.hi)).toBe(1);
});
it("does not stretch a constant series into nothing", () => {
const y = yScaleOf([5, 5], undefined, false);
expect(y.hi).toBeGreaterThan(y.lo);
});
it("puts a log axis on powers of ten", () => {
const y = yScaleOf([3, 4000], { scale: "log" }, true);
expect(y.ticks).toEqual([1, 10, 100, 1000, 10000]);
expect(y.at(10)).toBeCloseTo(0.25);
});
it("survives values near the bottom of the float range on a log axis", () => {
for (const tiny of [5e-324, 1e-320, 1e-310]) {
const y = yScaleOf([tiny, 10], { scale: "log" }, false);
expect(y.lo).toBeGreaterThan(0);
expect(y.ticks.length).toBeLessThan(700);
expect(Number.isFinite(y.at(tiny))).toBe(true);
}
expect(logTicks(0, 10).length).toBeLessThan(700);
});
it("returns a tick for a degenerate range", () => {
expect(niceTicks(2, 2)).toEqual([2]);
expect(logTicks(5, 6).length).toBeGreaterThan(0);
});
});
describe("stackOf", () => {
it("stacks positives upward and negatives downward from zero", () => {
const { lo, hi } = stackOf([{ label: "a", values: [2, -1] }, { label: "b", values: [3, -2] }]);
expect([lo[1][0], hi[1][0]]).toEqual([2, 5]);
expect([lo[1][1], hi[1][1]]).toEqual([-3, -1]);
});
});
+141
View File
@@ -0,0 +1,141 @@
import type { Cell, ChartAxis, ChartMark, ChartSpec } from "./spec";
export interface Series {
label: string;
/** One value per category; null is a gap. */
values: (number | null)[];
}
export interface Plot {
categories: string[];
/** Numeric or time position of each category, when the x column has one. */
positions: number[] | null;
series: Series[];
}
const colIndex = (spec: ChartSpec, name: string) => spec.data.columns.findIndex((c) => c.name === name);
const text = (c: Cell) => (c === null ? "" : String(c));
function position(spec: ChartSpec, x: number, c: Cell): number | null {
if (c === null) return null;
const type = spec.data.columns[x].type;
if (type === "number") return typeof c === "number" ? c : null;
if (type === "date") return Date.parse(String(c));
return null;
}
/** Folds the rows into series over categories in first-seen order. A repeated
* category/series pair keeps its last row: v1 specs carry no aggregation. */
export function plotOf(spec: ChartSpec, mark: ChartMark): Plot {
const x = colIndex(spec, mark.x);
const color = mark.color ? colIndex(spec, mark.color) : -1;
const cats = new Map<string, number>();
const keys = new Map<string, Series>();
const pos: number[] = [];
let numeric = spec.data.columns[x].type !== "string";
for (const row of spec.data.rows) {
const cat = text(row[x]);
if (!cats.has(cat)) {
cats.set(cat, cats.size);
const p = position(spec, x, row[x]);
if (p === null || Number.isNaN(p)) numeric = false;
else pos.push(p);
}
}
const at = (cat: string) => cats.get(cat) as number;
for (const row of spec.data.rows) {
for (const y of mark.y) {
const yi = colIndex(spec, y);
const name = color >= 0 ? (mark.y.length > 1 ? `${y} / ${text(row[color])}` : text(row[color])) : y;
let s = keys.get(name);
if (!s) keys.set(name, (s = { label: name, values: Array<number | null>(cats.size).fill(null) }));
const v = row[yi];
const i = at(text(row[x]));
while (s.values.length <= i) s.values.push(null);
s.values[i] = typeof v === "number" ? v : null;
}
}
const series = [...keys.values()];
for (const s of series) while (s.values.length < cats.size) s.values.push(null);
return { categories: [...cats.keys()], positions: numeric && pos.length === cats.size ? pos : null, series };
}
export interface Slice { label: string; value: number }
/** Pie slices are the positive rows; nulls and zeros take no angle. */
export function slicesOf(spec: ChartSpec, mark: ChartMark): Slice[] {
const p = plotOf(spec, mark);
const values = p.series[0]?.values ?? [];
return p.categories.flatMap((label, i) => (values[i] && values[i]! > 0 ? [{ label, value: values[i]! }] : []));
}
export function niceTicks(lo: number, hi: number, want = 5): number[] {
if (!(hi > lo)) return [lo];
const raw = (hi - lo) / Math.max(1, want);
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw) as number;
const out: number[] = [];
const first = Math.floor(lo / step + 1e-9);
const last = Math.ceil(hi / step - 1e-9);
for (let k = first; k <= last; k++) out.push(k * step);
return out;
}
const LOG_FLOOR = 1e-300;
export function logTicks(lo: number, hi: number): number[] {
const out: number[] = [];
const first = Math.ceil(Math.log10(Math.max(lo, LOG_FLOOR)));
const last = Math.floor(Math.log10(Math.max(hi, LOG_FLOOR)) + 1e-9);
for (let e = first; e <= last; e++) out.push(10 ** e);
return out.length ? out : [lo, hi];
}
export interface YScale {
lo: number;
hi: number;
log: boolean;
ticks: number[];
at: (v: number) => number;
}
/** Maps a value to 0 (bottom) .. 1 (top). Bars pass zero so their base is
* always drawn; a log scale cannot hold zero and starts at its smallest value. */
export function yScaleOf(values: number[], axis: ChartAxis | undefined, includeZero: boolean): YScale {
const log = axis?.scale === "log";
let lo = Math.min(...values);
let hi = Math.max(...values);
if (!Number.isFinite(lo)) { lo = 0; hi = 1; }
if (log) {
lo = Math.max(lo, LOG_FLOOR);
hi = Math.max(hi, lo);
lo = 10 ** Math.floor(Math.log10(lo));
hi = 10 ** Math.ceil(Math.log10(hi));
if (hi <= lo) hi = lo * 10;
const span = Math.log10(hi / lo);
return { lo, hi, log, ticks: logTicks(lo, hi), at: (v) => Math.log10(Math.max(v, lo) / lo) / span };
}
if (includeZero) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); }
if (hi === lo) hi = lo + 1;
const ticks = niceTicks(lo, hi);
lo = Math.min(lo, ticks[0]);
hi = Math.max(hi, ticks[ticks.length - 1]);
return { lo, hi, log, ticks, at: (v) => (v - lo) / (hi - lo) };
}
/** Stacked bars: positives climb from zero, negatives descend from it. */
export function stackOf(series: Series[]): { lo: number[][]; hi: number[][] } {
const n = series[0]?.values.length ?? 0;
const lo = series.map(() => Array<number>(n).fill(0));
const hi = series.map(() => Array<number>(n).fill(0));
for (let i = 0; i < n; i++) {
let up = 0;
let down = 0;
series.forEach((s, k) => {
const v = s.values[i] ?? 0;
if (v >= 0) { lo[k][i] = up; up += v; hi[k][i] = up; }
else { hi[k][i] = down; down += v; lo[k][i] = down; }
});
}
return { lo, hi };
}
@@ -0,0 +1,195 @@
import type { ChartMark, ChartSpec } from "./spec";
import { plotOf, slicesOf, stackOf, yScaleOf, type Plot, type YScale } from "./model";
import { clip, formatValue } from "./format";
import { t } from "../../i18n";
import { count, pct } from "../../i18n/format";
export const W = 640;
export const H = 280;
const TOP = 14;
const BOTTOM = 30;
const RIGHT = 14;
const FOCUSABLE_MAX = 60;
export type Hot = (text: string | null) => void;
interface Frame {
spec: ChartSpec;
mark: ChartMark;
hot: Hot;
hatch: string;
}
const seriesClass = (i: number) => `cs${i % 5} sv${Math.min(Math.floor(i / 5), 2)}`;
function pointProps(label: string, hot: Hot, focusable: boolean) {
return {
tabIndex: focusable ? 0 : undefined,
"aria-label": label,
role: "img" as const,
onMouseEnter: () => hot(label),
onMouseLeave: () => hot(null),
onFocus: () => hot(label),
onBlur: () => hot(null),
};
}
function Axes({ y, left, spec }: { y: YScale; left: number; spec: ChartSpec }) {
const base = H - BOTTOM;
const at = (v: number) => base - y.at(v) * (base - TOP);
return (
<g className="ax" aria-hidden="true">
{y.ticks.map((v) => (
<g key={v}>
<line className="grid" x1={left} x2={W - RIGHT} y1={at(v)} y2={at(v)} />
<text className="tick" x={left - 6} y={at(v)} textAnchor="end" dominantBaseline="middle">{formatValue(v, { ...spec.y_axis, unit: undefined })}</text>
</g>
))}
<line className="base" x1={left} x2={W - RIGHT} y1={base} y2={base} />
</g>
);
}
function leftMargin(y: YScale, spec: ChartSpec): number {
const widest = Math.max(...y.ticks.map((v) => formatValue(v, { ...spec.y_axis, unit: undefined }).length));
return Math.min(120, 14 + widest * 6.6);
}
function XLabels({ cats, xs, left }: { cats: string[]; xs: number[]; left: number }) {
const room = (W - RIGHT - left) / Math.max(1, cats.length);
const widest = Math.min(14, Math.max(...cats.map((c) => [...c].length)));
const every = Math.max(1, Math.ceil((widest * 6.4 + 10) / room));
return (
<g className="xl" aria-hidden="true">
{cats.map((c, i) => (i % every === 0 ? (
<text key={i} className="tick" x={xs[i]} y={H - BOTTOM + 16} textAnchor="middle">{clip(c, 14)}</text>
) : null))}
</g>
);
}
export function BarPlot({ spec, mark, hot, hatch }: Frame) {
const p = plotOf(spec, mark);
const stack = mark.stacked ? stackOf(p.series) : null;
const all = stack ? [...stack.lo.flat(), ...stack.hi.flat()] : p.series.flatMap((s) => s.values.filter((v): v is number => v !== null));
const y = yScaleOf(all, spec.y_axis, true);
const left = leftMargin(y, spec);
const base = H - BOTTOM;
const band = (W - RIGHT - left) / Math.max(1, p.categories.length);
const slot = stack ? band * 0.7 : (band * 0.76) / Math.max(1, p.series.length);
const at = (v: number) => base - y.at(v) * (base - TOP);
const zero = y.log ? base : at(0);
const focusable = p.categories.length * p.series.length <= FOCUSABLE_MAX;
const xs = p.categories.map((_, i) => left + band * (i + 0.5));
return (
<svg viewBox={`0 0 ${W} ${H}`} className="chart-svg" style={{ minWidth: W }}>
<Axes y={y} left={left} spec={spec} />
{p.series.map((s, k) => s.values.map((v, i) => {
if (v === null) return null;
const a = stack ? at(stack.hi[k][i]) : at(v);
const b = stack ? at(stack.lo[k][i]) : zero;
const x = stack ? xs[i] - slot / 2 : left + band * i + band * 0.12 + slot * k;
const label = `${s.label} · ${p.categories[i]} · ${formatValue(v, spec.y_axis)}`;
return (
<g key={`${k}:${i}`} className={`bar ${seriesClass(k)}`} {...pointProps(label, hot, focusable)}>
<rect x={x} y={Math.min(a, b)} width={Math.max(1, slot - 1)} height={Math.max(1, Math.abs(b - a))} />
{k >= 5 && <rect className="hatch" x={x} y={Math.min(a, b)} width={Math.max(1, slot - 1)} height={Math.max(1, Math.abs(b - a))} fill={`url(#${hatch})`} />}
</g>
);
}))}
<XLabels cats={p.categories} xs={xs} left={left} />
</svg>
);
}
function xPositions(p: Plot, left: number): number[] {
const right = W - RIGHT;
if (!p.positions) return p.categories.map((_, i) => (p.categories.length === 1 ? (left + right) / 2 : left + 12 + ((right - left - 24) * i) / (p.categories.length - 1)));
const lo = Math.min(...p.positions);
const hi = Math.max(...p.positions);
return p.positions.map((v) => (hi === lo ? (left + right) / 2 : left + 12 + ((v - lo) / (hi - lo)) * (right - left - 24)));
}
export function LinePlot({ spec, mark, hot }: Frame) {
const p = plotOf(spec, mark);
const y = yScaleOf(p.series.flatMap((s) => s.values.filter((v): v is number => v !== null)), spec.y_axis, false);
const left = leftMargin(y, spec);
const base = H - BOTTOM;
const at = (v: number) => base - y.at(v) * (base - TOP);
const xs = xPositions(p, left);
const order = p.positions ? p.categories.map((_, i) => i).sort((a, b) => p.positions![a] - p.positions![b]) : p.categories.map((_, i) => i);
const dots = p.categories.length * p.series.length <= FOCUSABLE_MAX;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="chart-svg" style={{ minWidth: W }}>
<Axes y={y} left={left} spec={spec} />
{p.series.map((s, k) => {
const runs: string[] = [];
let run = "";
for (const i of order) {
const v = s.values[i];
if (v === null) { if (run) runs.push(run); run = ""; continue; }
run += `${run ? "L" : "M"}${xs[i].toFixed(1)} ${at(v).toFixed(1)}`;
}
if (run) runs.push(run);
return (
<g key={k} className={`line ${seriesClass(k)}`}>
{runs.map((d, r) => <path key={r} d={d} />)}
{s.values.map((v, i) => {
if (v === null) return null;
const label = `${s.label} · ${p.categories[i]} · ${formatValue(v, spec.y_axis)}`;
return dots
? <circle key={i} className="pt" cx={xs[i]} cy={at(v)} r={3.4} {...pointProps(label, hot, true)} />
: null;
})}
</g>
);
})}
<XLabels cats={order.map((i) => p.categories[i])} xs={order.map((i) => xs[i])} left={left} />
</svg>
);
}
const TAU = Math.PI * 2;
function arc(cx: number, cy: number, r: number, inner: number, a0: number, a1: number): string {
const pt = (rad: number, a: number) => `${(cx + rad * Math.sin(a)).toFixed(2)} ${(cy - rad * Math.cos(a)).toFixed(2)}`;
if (a1 - a0 >= TAU - 1e-6) {
const ring = (rad: number) => `M${cx} ${cy - rad}A${rad} ${rad} 0 1 1 ${cx} ${cy + rad}A${rad} ${rad} 0 1 1 ${cx} ${cy - rad}`;
return inner ? `${ring(r)}${ring(inner)}` : ring(r);
}
const big = a1 - a0 > Math.PI ? 1 : 0;
const outer = `M${pt(r, a0)}A${r} ${r} 0 ${big} 1 ${pt(r, a1)}`;
return inner ? `${outer}L${pt(inner, a1)}A${inner} ${inner} 0 ${big} 0 ${pt(inner, a0)}Z` : `${outer}L${cx} ${cy}Z`;
}
export function PiePlot({ spec, mark, hot }: Frame) {
const slices = slicesOf(spec, mark);
const total = slices.reduce((n, s) => n + s.value, 0);
if (slices.length === 0) return <div className="chart-empty">{t("没有可画的数据")}</div>;
const r = 104;
let angle = 0;
return (
<div className="chart-pie">
<svg viewBox={`0 0 ${W / 2} ${H}`} className="chart-svg pie">
{slices.map((s, i) => {
const a0 = angle;
angle += (s.value / total) * TAU;
const label = `${s.label} · ${formatValue(s.value, spec.y_axis)} · ${pct(s.value / total, 1)}`;
return (
<path key={i} className={`slice ${seriesClass(i)}`} d={arc(W / 4, H / 2, r, mark.donut ? r * 0.58 : 0, a0, angle)} fillRule="evenodd" {...pointProps(label, hot, slices.length <= FOCUSABLE_MAX)} />
);
})}
</svg>
<ul className="chart-key">
{slices.slice(0, 12).map((s, i) => (
<li key={i}>
<i className={seriesClass(i)} />
<span className="nm">{clip(s.label, 24)}</span>
<span className="v">{pct(s.value / total, 1)}</span>
</li>
))}
{slices.length > 12 && <li className="more">{`+${count(slices.length - 12)}`}</li>}
</ul>
</div>
);
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { chartOfTool, readChartSpec } from "./spec";
import { SALES, chartCall, withSpec } from "./fixtures";
const rows = (n: number) => Array.from({ length: n }, (_, i) => [`c${i}`, i]);
describe("readChartSpec", () => {
it("accepts a spec the kernel would accept", () => {
expect(readChartSpec(structuredClone(SALES))?.title).toBe("Sales");
});
const bad: [string, unknown][] = [
["a future version", { ...SALES, spec_version: 2 }],
["markup-free but oversized title", withSpec({ title: "x".repeat(65) })],
["a control character in a label", withSpec({ title: `a${String.fromCharCode(7)}b` })],
["a bidi override in a label", withSpec({ title: `a${String.fromCharCode(0x202e)}b` })],
["an unknown mark", withSpec({ marks: [{ type: "radar", x: "month", y: ["revenue"] }] })],
["a mark naming a column nobody declared", withSpec({ marks: [{ type: "bar", x: "nope", y: ["revenue"] }] })],
["a non-numeric y column", withSpec({ marks: [{ type: "bar", x: "month", y: ["month"] }] })],
["a stacked line", withSpec({ marks: [{ type: "line", x: "month", y: ["revenue"], stacked: true }] })],
["a pie with two series", withSpec({ marks: [{ type: "pie", x: "month", y: ["revenue", "revenue"] }] })],
["a negative pie value", withSpec({ data: { ...SALES.data, rows: [["a", -1]] }, marks: [{ type: "pie", x: "month", y: ["revenue"] }] })],
["zero on a log axis", withSpec({ data: { ...SALES.data, rows: [["a", 0]] }, y_axis: { scale: "log" } })],
["duplicate column names", withSpec({ data: { columns: [{ name: "a", type: "string" }, { name: "a", type: "number" }], rows: [] } })],
["a row of the wrong width", withSpec({ data: { ...SALES.data, rows: [["a"]] } })],
["a string in a number column", withSpec({ data: { ...SALES.data, rows: [["a", "1"]] } })],
["an integer past 2^53", withSpec({ data: { ...SALES.data, rows: [["a", 2 ** 63]] } })],
["a non-finite number", withSpec({ data: { ...SALES.data, rows: [["a", Infinity]] } })],
["a nested cell", withSpec({ data: { ...SALES.data, rows: [["a", [1]]] } })],
["an unparseable date", withSpec({ data: { columns: [{ name: "d", type: "date" }, { name: "v", type: "number" }], rows: [["soon", 1]] }, marks: [{ type: "line", x: "d", y: ["v"] }] })],
["more rows than the cap", withSpec({ data: { ...SALES.data, rows: rows(5001) } })],
["more points than the cap", withSpec({ data: { ...SALES.data, rows: rows(2001) } })],
["more series than the cap", withSpec({ data: { columns: [{ name: "x", type: "string" }, { name: "g", type: "string" }, { name: "v", type: "number" }], rows: Array.from({ length: 13 }, (_, i) => ["a", `g${i}`, i]) }, marks: [{ type: "line", x: "x", y: ["v"], color: "g" }] })],
["no marks", withSpec({ marks: [] })],
["a bad axis format", withSpec({ x_axis: { format: "%d" } })],
];
it.each(bad)("refuses %s", (_name, spec) => {
expect(readChartSpec(spec)).toBeNull();
});
it("refuses non-objects and nesting without throwing", () => {
for (const v of [null, 1, "x", [], { spec_version: 1, data: 5 }]) expect(readChartSpec(v)).toBeNull();
});
});
describe("chartOfTool", () => {
it("reads the spec out of a use_capability call", () => {
expect(chartOfTool(chartCall(SALES))?.title).toBe("Sales");
});
it("reads a direct render_chart call too", () => {
const tool = chartCall(SALES, { name: "render_chart", args: JSON.stringify(SALES), resolvedName: undefined });
expect(chartOfTool(tool)?.title).toBe("Sales");
});
it("draws nothing for a call that is still running", () => {
expect(chartOfTool(chartCall(SALES, { output: undefined }))).toBeNull();
});
it("draws nothing for a call the kernel refused", () => {
expect(chartOfTool(chartCall(SALES, { err: "chart.schema_invalid", output: "chart.schema_invalid" }))).toBeNull();
});
it("draws nothing for a lookup of the tool rather than a call", () => {
for (const action of ["search", "inspect"]) {
const tool = chartCall(SALES, { args: JSON.stringify({ action, capability_id: "tool:render_chart", arguments: SALES }) });
expect(chartOfTool(tool), action).toBeNull();
}
});
it("draws nothing for other tools or malformed arguments", () => {
expect(chartOfTool(chartCall(SALES, { resolvedName: "web_fetch" }))).toBeNull();
expect(chartOfTool(chartCall(SALES, { args: "{not json" }))).toBeNull();
expect(chartOfTool(chartCall({ ...SALES, extra: 1, marks: [] }))).toBeNull();
});
});
+128
View File
@@ -0,0 +1,128 @@
import type { Tool } from "../../port/wire";
export const CHART_TOOL = "render_chart";
export const MAX_ROWS = 5000;
export const MAX_COLUMNS = 32;
export const MAX_SERIES = 12;
export const MAX_POINTS = 2000;
export const MAX_MARKS = 8;
export const MAX_LABEL = 64;
export type ColumnType = "number" | "string" | "date";
export type MarkType = "bar" | "line" | "pie";
export type Cell = number | string | null;
export interface ChartColumn {
name: string;
type: ColumnType;
}
export interface ChartMark {
type: MarkType;
x: string;
y: string[];
color?: string;
stacked?: boolean;
donut?: boolean;
}
export interface ChartAxis {
title?: string;
unit?: string;
scale?: "linear" | "log";
format?: "integer" | "decimal" | "percent";
}
export interface ChartData {
columns: ChartColumn[];
rows: Cell[][];
}
export interface ChartSpec {
spec_version: 1;
title: string;
data: ChartData;
marks: ChartMark[];
x_axis?: ChartAxis;
y_axis?: ChartAxis;
}
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
const CONTROL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
function label(v: unknown, required = false): v is string {
if (v === undefined) return !required;
return typeof v === "string" && [...v].length <= MAX_LABEL && !CONTROL.test(v);
}
function axis(v: unknown): ChartAxis | undefined | false {
if (v === undefined) return undefined;
if (!isObject(v) || !label(v.title) || !label(v.unit)) return false;
if (v.scale !== undefined && v.scale !== "linear" && v.scale !== "log") return false;
if (v.format !== undefined && v.format !== "integer" && v.format !== "decimal" && v.format !== "percent") return false;
return v as ChartAxis;
}
function column(c: unknown): c is ChartColumn {
return isObject(c) && label(c.name, true) && c.name !== "" && (c.type === "number" || c.type === "string" || c.type === "date");
}
function cell(v: unknown, type: ColumnType): v is Cell {
if (v === null) return true;
if (type === "number") return typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= 2 ** 53;
if (typeof v !== "string" || !label(v, true)) return false;
return type !== "date" || !Number.isNaN(Date.parse(v));
}
function mark(m: unknown, cols: Map<string, ColumnType>, rows: Cell[][], yScale: string | undefined): m is ChartMark {
if (!isObject(m) || (m.type !== "bar" && m.type !== "line" && m.type !== "pie")) return false;
if ((m.stacked && m.type !== "bar") || (m.donut && m.type !== "pie")) return false;
if (typeof m.x !== "string" || !cols.has(m.x)) return false;
if (!Array.isArray(m.y) || m.y.length === 0 || m.y.some((y) => typeof y !== "string" || cols.get(y) !== "number")) return false;
if (m.type === "pie" && (m.y.length !== 1 || m.color !== undefined || yScale === "log")) return false;
let series = m.y.length;
if (m.color !== undefined) {
if (typeof m.color !== "string" || cols.get(m.color) !== "string") return false;
const at = [...cols.keys()].indexOf(m.color);
series *= new Set(rows.map((r) => r[at]).filter((v) => typeof v === "string")).size;
}
if (series > MAX_SERIES || rows.length * m.y.length > MAX_POINTS) return false;
const ys = (m.y as string[]).map((y) => [...cols.keys()].indexOf(y));
return rows.every((r) => ys.every((i) => {
const v = r[i];
return typeof v !== "number" || !((m.type === "pie" && v < 0) || (yScale === "log" && v <= 0));
}));
}
/** Re-checks a spec the way the kernel's validator does. A stored call is
* untrusted input, so a spec that fails here is never drawn. */
export function readChartSpec(raw: unknown): ChartSpec | null {
if (!isObject(raw) || raw.spec_version !== 1 || !label(raw.title, true)) return null;
const x = axis(raw.x_axis);
const y = axis(raw.y_axis);
if (x === false || y === false || !isObject(raw.data)) return null;
const { columns, rows } = raw.data;
if (!Array.isArray(columns) || columns.length === 0 || columns.length > MAX_COLUMNS || !columns.every(column)) return null;
const types = new Map(columns.map((c) => [c.name, c.type] as const));
if (types.size !== columns.length || !Array.isArray(rows) || rows.length > MAX_ROWS) return null;
if (!rows.every((r) => Array.isArray(r) && r.length === columns.length && r.every((v, i) => cell(v, columns[i].type)))) return null;
const marks = raw.marks;
if (!Array.isArray(marks) || marks.length === 0 || marks.length > MAX_MARKS) return null;
if (!marks.every((m) => mark(m, types, rows as Cell[][], y?.scale))) return null;
return raw as unknown as ChartSpec;
}
/** The chart a tool call carries, when it is a settled render_chart. A call
* that failed, is still running, or only looked the tool up draws nothing. */
export function chartOfTool(tool: Tool): ChartSpec | null {
if (tool.err || tool.output === undefined) return null;
if (tool.name !== CHART_TOOL && tool.resolvedName !== CHART_TOOL) return null;
try {
const args = JSON.parse(tool.args ?? "");
if (tool.name === CHART_TOOL) return readChartSpec(args);
return isObject(args) && args.action === "call" ? readChartSpec(args.arguments) : null;
} catch {
return null;
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Item } from "../state/session";
import { chartOfTool } from "./chart/spec";
type Row = { item: Item; activity?: Item[] } | { activity: Item[] };
@@ -23,7 +24,7 @@ export function transcriptRows(all: Item[]): Row[] {
while (end < items.length && items[end].t !== "user") end++;
const turn: Row[] = [];
for (const item of items.slice(at, end)) {
const work = item.t === "tool" || item.t === "reads";
const work = (item.t === "tool" && !chartOfTool(item.tool)) || item.t === "reads";
const last = turn[turn.length - 1];
if (work && last && "activity" in last && !("item" in last)) last.activity.push(item);
else turn.push(work ? { activity: [item] } : { item });
+8
View File
@@ -25,6 +25,7 @@ const (
tsProviderFile = "desktop/frontend-next/src/port/provider.ts"
tsFeedbackFile = "desktop/frontend-next/src/port/feedback.ts"
tsWorkspaceFile = "desktop/frontend-next/src/port/workspace.ts"
tsChartFile = "desktop/frontend-next/src/ui/chart/spec.ts"
)
// mirroredWireTypes are the Go types the desktop keeps a second, hand-written
@@ -145,6 +146,13 @@ var mirroredWireTypes = []wireMirror{
{"internal/session/control/commit.go", "CommitRequest", tsWorkspaceFile, "CommitRequest"},
{"internal/session/control/commit.go", "CommitResult", tsWorkspaceFile, "CommitResult"},
{"internal/platform/gitcommit/gitcommit.go", "File", tsWorkspaceFile, "CommitFile"},
// The chart spec a stored render_chart call carries. The page re-validates it
// on load, so a field it cannot read is one the card silently drops.
{"internal/contract/chartspec/spec.go", "Spec", tsChartFile, "ChartSpec"},
{"internal/contract/chartspec/spec.go", "Data", tsChartFile, "ChartData"},
{"internal/contract/chartspec/spec.go", "Column", tsChartFile, "ChartColumn"},
{"internal/contract/chartspec/spec.go", "Mark", tsChartFile, "ChartMark"},
{"internal/contract/chartspec/spec.go", "Axis", tsChartFile, "ChartAxis"},
}
type wireMirror struct {