Merge feat/ui-polish: wave 4 UI — activity dock, stats bar, plan blocks + popup, sidebar retirement
This commit is contained in:
@@ -43,7 +43,6 @@ import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
|
||||
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
|
||||
import { JobCard } from "../components/artifacts/job-card.tsx";
|
||||
import { JobsContext } from "../components/jobs-context.ts";
|
||||
import { SubAgentsPanel } from "../components/sub-agents-panel.tsx";
|
||||
import { demoInputSchema, jobTools, visualInputSchema } from "../src/agent/tools/jobs.ts";
|
||||
import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||
@@ -393,25 +392,6 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sub-agents panel (sidebar observability)", () => {
|
||||
const runningJob = (kind: "research" | "visual"): JobRecord =>
|
||||
({ id: `j-${kind}`, kind, chatId: "c", toolCallId: "t", status: "running", activity: 'web_search: "sigma"', createdAt: new Date(Date.now() - 40000).toISOString(), updatedAt: new Date().toISOString() }) as JobRecord;
|
||||
|
||||
it("lists running sub-agents with kind, activity, and elapsed", () => {
|
||||
const html = renderToStaticMarkup(<SubAgentsPanel jobs={[runningJob("research"), runningJob("visual")]} />);
|
||||
assert.match(html, /Sub-agents/);
|
||||
assert.match(html, /Researcher/);
|
||||
assert.match(html, /Figure maker/);
|
||||
assert.match(html, /web_search: ("|")sigma/);
|
||||
assert.match(html, /40s|\d+m/);
|
||||
});
|
||||
|
||||
it("renders nothing when the fleet is idle (done jobs vanish)", () => {
|
||||
const done = { ...runningJob("research"), status: "done" };
|
||||
assert.equal(renderToStaticMarkup(<SubAgentsPanel jobs={[done as JobRecord]} />), "");
|
||||
assert.equal(renderToStaticMarkup(<SubAgentsPanel jobs={[]} />), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSvg", () => {
|
||||
it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ActivityDock, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx";
|
||||
import { formatTokens, formatUsd } from "../components/usage-format.ts";
|
||||
|
||||
// The dock's pure layer: folds, formatting, and blob order — then the SSR
|
||||
// render (blobs, "—" doctrine, the abort affordance, the narrow badge).
|
||||
|
||||
const NOW = Date.parse("2026-09-09T12:00:00Z");
|
||||
|
||||
/** A full CallUsage (its fields are undefined-valued, not optional). */
|
||||
function step(inputTokens: number, outputTokens: number) {
|
||||
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
||||
}
|
||||
|
||||
function job(over: Partial<ActivityFeed["jobs"][number]> = {}): ActivityFeed["jobs"][number] {
|
||||
return {
|
||||
id: "job-1",
|
||||
kind: "research",
|
||||
chatId: "c1",
|
||||
toolCallId: "t1",
|
||||
status: "running",
|
||||
activity: 'web_search: "sigma.js v3"',
|
||||
createdAt: new Date(NOW - 45_000).toISOString(),
|
||||
updatedAt: new Date(NOW - 1000).toISOString(),
|
||||
title: "Limits lesson",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("activity dock pure layer", () => {
|
||||
it("merges jobs + extractions oldest-first (the stable blob order)", () => {
|
||||
const feed: ActivityFeed = {
|
||||
jobs: [job({ id: "new", createdAt: new Date(NOW - 10_000).toISOString() })],
|
||||
extractions: [{ chatId: "cx", title: "Old lesson", phase: "auditing", startedAt: NOW - 100_000 }],
|
||||
};
|
||||
const blobs = toBlobs(feed);
|
||||
assert.equal(blobs.length, 2);
|
||||
const first = blobs[0]!;
|
||||
assert.equal(first.id, "cx"); // extraction started first
|
||||
assert.equal(first.process, "extraction");
|
||||
assert.equal(first.phase, "auditing");
|
||||
assert.equal(blobs[1]!.id, "new");
|
||||
});
|
||||
|
||||
it("folds cost with jobCost; absent usage is null, never zero", () => {
|
||||
const priced = job({
|
||||
usage: {
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
inputTokens: 10_000,
|
||||
outputTokens: 1000,
|
||||
cacheReadTokens: 5000,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
steps: 2,
|
||||
},
|
||||
});
|
||||
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
|
||||
// (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
|
||||
assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
|
||||
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
|
||||
assert.equal(absent.cost, null);
|
||||
});
|
||||
|
||||
it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => {
|
||||
const base = {
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
inputTokens: 400_000, // running total (much bigger — must NOT feed the line)
|
||||
outputTokens: 8_000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
steps: 5,
|
||||
};
|
||||
const withLast = job({
|
||||
usage: { ...base, lastStep: step(90_000, 2_000) },
|
||||
});
|
||||
const blob = toBlobs({ jobs: [withLast], extractions: [] })[0]!;
|
||||
assert.equal(blob.context?.used, 92_000);
|
||||
assert.equal(blob.context?.limit, 1310720);
|
||||
// No lastStep yet (capture pending) → "—", even though usage exists.
|
||||
const stripped = toBlobs({ jobs: [job({ usage: { ...base } })], extractions: [] })[0]!;
|
||||
assert.equal(stripped.context, null);
|
||||
// Unknown model → no limit → "—".
|
||||
const unknownModel = toBlobs({
|
||||
jobs: [job({ usage: { ...base, model: "nope/unknown", lastStep: step(5, 5) } })],
|
||||
extractions: [],
|
||||
})[0]!;
|
||||
assert.equal(unknownModel.context, null);
|
||||
});
|
||||
|
||||
it("extraction blobs carry no money/context (—, the doctrine)", () => {
|
||||
const blob = toBlobs({
|
||||
jobs: [],
|
||||
extractions: [{ chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }],
|
||||
})[0]!;
|
||||
assert.equal(blob.cost, null);
|
||||
assert.equal(blob.context, null);
|
||||
});
|
||||
|
||||
it("formatElapsed ticks compactly", () => {
|
||||
assert.equal(formatElapsed(45_000), "45s");
|
||||
assert.equal(formatElapsed(184_000), "3m 04s");
|
||||
assert.equal(formatElapsed(4_320_000), "1h 12m");
|
||||
});
|
||||
|
||||
it("formatUsd keeps four decimals under $1, two above", () => {
|
||||
assert.equal(formatUsd(0.0014), "$0.0014");
|
||||
assert.equal(formatUsd(1.5), "$1.50");
|
||||
});
|
||||
|
||||
it("formatTokens trims (never 38.0k)", () => {
|
||||
assert.equal(formatTokens(38_000), "38k");
|
||||
assert.equal(formatTokens(1_234_567), "1.2M");
|
||||
assert.equal(formatTokens(950), "950");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity dock render", () => {
|
||||
it("renders NOTHING when idle (no jobs, no extractions)", () => {
|
||||
assert.equal(renderToStaticMarkup(<ActivityDock feed={{ jobs: [], extractions: [] }} />), "");
|
||||
});
|
||||
|
||||
it("renders one blob per running process with cost, elapsed, and the abort affordance", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ActivityDock
|
||||
feed={{
|
||||
jobs: [job({ id: "j1", kind: "demo", activity: "building sandbox…", title: "Chain-rule demo" })],
|
||||
extractions: [{ chatId: "cx", title: "Old lesson", phase: "merging", startedAt: NOW - 60_000 }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
assert.match(html, /data-testid="activity-dock"/);
|
||||
assert.match(html, /data-testid="activity-blob-job"/);
|
||||
assert.match(html, /data-testid="activity-blob-extraction"/);
|
||||
assert.match(html, /Chain-rule demo/);
|
||||
assert.match(html, /building sandbox…/);
|
||||
assert.match(html, /Extraction — merging/);
|
||||
// Unpriced job → "—", never $0.0000 (doctrine); both blobs show it.
|
||||
assert.match(html, /activity-blob-cost[^>]*>—</);
|
||||
assert.match(html, /data-testid="activity-abort"/, "END on the blob face, popover-independent");
|
||||
assert.match(html, /\bEnd\b/, "the explicit END button");
|
||||
assert.match(html, /Background activity/);
|
||||
// Total cost: nothing priced → "—" in the header.
|
||||
assert.match(html, /activity-total-cost[^>]*>—</);
|
||||
});
|
||||
|
||||
it("shows total running cost when jobs are priced", () => {
|
||||
const usage = {
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
inputTokens: 10_000,
|
||||
outputTokens: 1000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
steps: 1,
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
<ActivityDock
|
||||
feed={{
|
||||
jobs: [
|
||||
job({ id: "a", usage }),
|
||||
job({ id: "b", usage, createdAt: new Date(NOW - 20_000).toISOString() }),
|
||||
],
|
||||
extractions: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// two identical priced jobs → 2 × $0.002 = $0.0040 total
|
||||
assert.match(html, /activity-total-cost[^>]*>\$0\.0040</);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
PlanBlock,
|
||||
PlanPopup,
|
||||
blockLayout,
|
||||
topoFor,
|
||||
wrapTitle,
|
||||
GAP_X,
|
||||
NODE_H,
|
||||
NODE_W,
|
||||
type PopupMessage,
|
||||
} from "../components/plan-block.tsx";
|
||||
import type { PlanBlockData, PlanTree } from "../src/core/contracts.ts";
|
||||
|
||||
// Plan blocks: the pure layered layout, the frozen-snapshot rendering
|
||||
// (full = borderless + column-filling, neighborhood = labeled fragment
|
||||
// with the glowing current), and the popup's LIVE derivation.
|
||||
|
||||
const CHAIN: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "a", title: "Alpha node" },
|
||||
{ id: "b", title: "Beta node" },
|
||||
{ id: "c", title: "Gamma node" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe("plan block layout (pure)", () => {
|
||||
it("layers a chain top-down, one node per depth row", () => {
|
||||
const { order, depth } = topoFor(CHAIN.nodes, CHAIN.edges);
|
||||
assert.deepEqual(order.map((n) => n.id), ["a", "b", "c"]);
|
||||
assert.equal(depth.get("c"), 2);
|
||||
const layout = blockLayout(CHAIN.nodes, CHAIN.edges);
|
||||
assert.equal(layout.width, NODE_W); // widest row is the single-node rows
|
||||
assert.equal(layout.height, 3 * NODE_H + 2 * 48);
|
||||
});
|
||||
|
||||
it("centers same-depth nodes over the widest row; tolerates dangling edges", () => {
|
||||
const nodes = [
|
||||
{ id: "r", title: "Root" },
|
||||
{ id: "x", title: "Left" },
|
||||
{ id: "y", title: "Right" },
|
||||
];
|
||||
const layout = blockLayout(nodes, [
|
||||
{ from: "r", to: "x" },
|
||||
{ from: "r", to: "y" },
|
||||
{ from: "ghost", to: "r" }, // dangling: ignored defensively
|
||||
]);
|
||||
assert.equal(layout.width, 2 * NODE_W + GAP_X);
|
||||
// Root centered over its two children: (2*W+GAP - W)/2
|
||||
assert.equal(layout.x.get("r"), (2 * NODE_W + GAP_X - NODE_W) / 2);
|
||||
assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X);
|
||||
});
|
||||
|
||||
it("wrapTitle wraps greedily and ellipsizes overflow", () => {
|
||||
assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line
|
||||
assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]);
|
||||
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), ["supercalifragilisti…", "word"]); // oversized word hard-clamped, overflow dropped
|
||||
assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan block render", () => {
|
||||
it("full block: borderless, column-filling, statuses AS CARRIED (never re-derived)", () => {
|
||||
const data: PlanBlockData = {
|
||||
kind: "full",
|
||||
at: "2026-09-09T12:00:00Z",
|
||||
current: null,
|
||||
nodes: [
|
||||
{ id: "a", title: "Alpha node", status: "done" },
|
||||
{ id: "b", title: "Beta node", status: "upcoming" },
|
||||
],
|
||||
edges: [{ from: "a", to: "b" }],
|
||||
};
|
||||
const html = renderToStaticMarkup(<PlanBlock data={data} onOpen={() => {}} />);
|
||||
assert.match(html, /data-testid="plan-block-full"/);
|
||||
// Borderless: the wrapper carries no border/rounded chrome.
|
||||
assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome");
|
||||
// Fills the column: the svg is w-full with the layout extent as viewBox
|
||||
// (leftmost/rightmost nodes hug the edges), height natural.
|
||||
assert.match(html, /<svg[^>]*class="block h-auto w-full"/);
|
||||
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column.
|
||||
assert.match(html, /viewBox="0 0 160 136"/);
|
||||
// Snapshot statuses render as carried.
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="done"/);
|
||||
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);
|
||||
assert.match(html, /Open the full plan/);
|
||||
});
|
||||
|
||||
it("neighborhood block: own small container, labeled fragment, glowing current", () => {
|
||||
const data: PlanBlockData = {
|
||||
kind: "neighborhood",
|
||||
at: "2026-09-09T12:00:00Z",
|
||||
current: "mid",
|
||||
nodes: [
|
||||
{ id: "up", title: "Upstream", status: "done" },
|
||||
{ id: "mid", title: "Current node", status: "current" },
|
||||
{ id: "down", title: "Downstream", status: "upcoming" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "up", to: "mid" },
|
||||
],
|
||||
};
|
||||
const html = renderToStaticMarkup(<PlanBlock data={data} onOpen={() => {}} />);
|
||||
assert.match(html, /data-testid="plan-block-neighborhood"/);
|
||||
assert.match(html, /class="[^"]*border/, "the fragment has its own container");
|
||||
assert.match(html, /Plan fragment/);
|
||||
// The glow: a pulsing primary halo around the current node.
|
||||
assert.match(html, /stroke-primary\/70 animate-pulse/);
|
||||
assert.match(html, /data-testid="plan-node-mid" data-status="current"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan popup (live derivation)", () => {
|
||||
function messages(): PopupMessage[] {
|
||||
return [
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
type: "tool-plan_init",
|
||||
state: "output-available",
|
||||
output: { ok: true, tree: CHAIN },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
type: "tool-quiz",
|
||||
state: "output-available",
|
||||
input: { nodeId: "a" },
|
||||
output: { correct: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
type: "tool-quiz",
|
||||
state: "input-available",
|
||||
input: { nodeId: "b" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it("derives statuses at click time: passed → done, live check → current (glowing)", () => {
|
||||
const html = renderToStaticMarkup(<PlanPopup messages={messages()} onClose={() => {}} />);
|
||||
assert.match(html, /data-testid="plan-popup"/);
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="done"/);
|
||||
assert.match(html, /data-testid="plan-node-b" data-status="current"/);
|
||||
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
|
||||
// The current node is the glow target.
|
||||
assert.match(html, /stroke-primary\/70 animate-pulse/);
|
||||
assert.match(html, /live — statuses derive from your check outcomes/);
|
||||
});
|
||||
|
||||
it("renders the empty state when the session has no plan", () => {
|
||||
const html = renderToStaticMarkup(<PlanPopup messages={[{ parts: [] }]} onClose={() => {}} />);
|
||||
assert.match(html, /No plan in this session yet\./);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { StatsBar } from "../components/stats-bar.tsx";
|
||||
import { prettyModel } from "../components/usage-format.ts";
|
||||
import type { TurnUsage } from "../src/core/contracts.ts";
|
||||
|
||||
// The stats bar: folds only (no cost math in the component), the "—"
|
||||
// doctrine for absent data, honest cost (never a fake $0), and the
|
||||
// model/thinking placeholder chip for the coming control.
|
||||
|
||||
function step(inputTokens: number, outputTokens: number, over: Record<string, number | undefined> = {}) {
|
||||
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined, ...over };
|
||||
}
|
||||
|
||||
function turn(over: Partial<TurnUsage> = {}): TurnUsage {
|
||||
return {
|
||||
messageId: "m1",
|
||||
at: "2026-09-09T12:00:00Z",
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
steps: [step(10_000, 1_000)],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stats bar", () => {
|
||||
it("renders — everywhere when the record has no turns yet (doctrine)", () => {
|
||||
const html = renderToStaticMarkup(<StatsBar turns={[]} />);
|
||||
assert.match(html, /data-testid="stats-bar"/);
|
||||
assert.match(html, /data-testid="stats-context">—</);
|
||||
assert.match(html, /data-testid="stats-cost">—</);
|
||||
assert.match(html, /data-testid="stats-model">—</);
|
||||
assert.match(html, /Thinking —</);
|
||||
});
|
||||
|
||||
it("folds context + cost from the turns log (core/prices, no local math)", () => {
|
||||
const html = renderToStaticMarkup(<StatsBar turns={[turn(), turn()]} />);
|
||||
// last turn's last call: 10k in + 1k out = 11k; limit 1,310,720 → "1.3M"
|
||||
assert.match(html, /data-testid="stats-context">11k \/ 1\.3M</);
|
||||
// each turn: 10k fresh input ×0.15 + 1k output ×0.5 = $0.002 → two = $0.0040
|
||||
assert.match(html, /data-testid="stats-cost">\$0\.0040</);
|
||||
// model chip: pretty name from the last turn's model id
|
||||
assert.match(html, /data-testid="stats-model">Glm 5\.3 Flash</);
|
||||
});
|
||||
|
||||
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
|
||||
const html = renderToStaticMarkup(<StatsBar turns={[turn({ model: "nope/unknown" })]} />);
|
||||
assert.match(html, /data-testid="stats-cost">—</);
|
||||
// unknown model also kills the context limit
|
||||
assert.match(html, /data-testid="stats-context">—</);
|
||||
});
|
||||
|
||||
it("prettyModel strips vendor + date suffix, capitalizes", () => {
|
||||
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
||||
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "Qwen3.8 Max");
|
||||
});
|
||||
});
|
||||
@@ -1,498 +0,0 @@
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
latestTree,
|
||||
nextNodeId,
|
||||
topoLayout,
|
||||
TreePanel,
|
||||
TreePanelRail,
|
||||
type TreePanelMessage,
|
||||
} from "../components/tree-panel.tsx";
|
||||
import type { PlanTree } from "../src/core/contracts.ts";
|
||||
|
||||
/**
|
||||
* Render + derivation tests for the persistent plan-tree panel (W7, vision
|
||||
* §2 "Persistent tree view"). Pattern follows render-cards.test.tsx: static
|
||||
* HTML via react-dom/server, assertions on the markup — plus unit coverage
|
||||
* for the pure helpers (latestTree / topoLayout / nextNodeId) that drive the
|
||||
* panel from the transcript's plan tool outputs.
|
||||
*/
|
||||
|
||||
/** A small DAG authored in NON-topological node order (b, a, c): a → b → c. */
|
||||
const chainTree: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "b", title: "Chain middle", summary: "The middle step." },
|
||||
{ id: "a", title: "Foundation", summary: "Where it all starts." },
|
||||
{ id: "c", title: "Summit", summary: "The payoff." },
|
||||
],
|
||||
edges: [
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
],
|
||||
currentNodeId: "b",
|
||||
doneNodeIds: ["a"],
|
||||
version: 3,
|
||||
};
|
||||
|
||||
function planPart(tool: string, tree: PlanTree, ok = true) {
|
||||
return {
|
||||
type: `tool-${tool}`,
|
||||
state: "output-available",
|
||||
toolCallId: `${tool}-1`,
|
||||
output: { ok, tree },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test literals carry real UI-part fields (toolCallId, text, …) beyond the
|
||||
* narrow structural slice TreePanelMessage needs — typed once at the boundary
|
||||
* so the fixtures can read like real transcript parts.
|
||||
*/
|
||||
function asMessages(raw: unknown[]): TreePanelMessage[] {
|
||||
return raw as TreePanelMessage[];
|
||||
}
|
||||
|
||||
describe("latestTree (the transcript is the data source)", () => {
|
||||
it("returns null when no plan tool output exists (probe phase / pre-plan sessions)", () => {
|
||||
const messages = asMessages([
|
||||
{ parts: [{ type: "text", text: "hello" }] },
|
||||
{ parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] },
|
||||
]);
|
||||
assert.equal(latestTree(messages), null);
|
||||
});
|
||||
|
||||
it("returns the LAST output-available plan tree (set_current overrides init)", () => {
|
||||
const initTree: PlanTree = { ...chainTree, currentNodeId: null, doneNodeIds: [], version: 1 };
|
||||
const messages = asMessages([
|
||||
{ parts: [planPart("plan_init", initTree)] },
|
||||
{ parts: [planPart("plan_set_current", chainTree)] },
|
||||
]);
|
||||
const tree = latestTree(messages);
|
||||
assert.equal(tree?.currentNodeId, "b");
|
||||
assert.equal(tree?.version, 3);
|
||||
});
|
||||
|
||||
it("skips plan parts still streaming or pending their output", () => {
|
||||
const initTree: PlanTree = { ...chainTree, version: 1 };
|
||||
const messages = asMessages([
|
||||
{
|
||||
parts: [
|
||||
planPart("plan_init", initTree),
|
||||
{ type: "tool-plan_mark_done", state: "input-streaming", toolCallId: "md1" },
|
||||
],
|
||||
},
|
||||
{ parts: [{ type: "tool-plan_set_current", state: "input-available", toolCallId: "sc1" }] },
|
||||
]);
|
||||
// The pending mark_done must not shadow the init output behind it.
|
||||
assert.equal(latestTree(messages)?.version, 1);
|
||||
});
|
||||
|
||||
it("counts a failed op's output (it carries the current tree) but skips malformed output", () => {
|
||||
const failedOp = asMessages([{ parts: [planPart("plan_add_edge", chainTree, false)] }]);
|
||||
assert.equal(latestTree(failedOp)?.version, 3, "ok:false still carries the live tree");
|
||||
|
||||
const malformed = asMessages([
|
||||
{ parts: [{ type: "tool-plan_init", state: "output-available", toolCallId: "p1", output: { ok: true } }] },
|
||||
{ parts: [planPart("plan_mark_done", chainTree)] },
|
||||
]);
|
||||
assert.equal(latestTree(malformed)?.version, 3, "falls through a treeless output to the earlier one");
|
||||
});
|
||||
});
|
||||
|
||||
describe("topoLayout (order + depth)", () => {
|
||||
it("orders nodes topologically even when authored out of order", () => {
|
||||
const { order } = topoLayout(chainTree);
|
||||
assert.deepEqual(
|
||||
order.map((n) => n.id),
|
||||
["a", "b", "c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("computes longest-path depth for indentation", () => {
|
||||
const { depth } = topoLayout(chainTree);
|
||||
assert.equal(depth.get("a"), 0);
|
||||
assert.equal(depth.get("b"), 1);
|
||||
assert.equal(depth.get("c"), 2);
|
||||
});
|
||||
|
||||
it("keeps every node visible even if an edge references an unknown id", () => {
|
||||
const broken: PlanTree = {
|
||||
...chainTree,
|
||||
edges: [{ from: "ghost", to: "a" }],
|
||||
};
|
||||
assert.equal(topoLayout(broken).order.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextNodeId (the unlocked frontier)", () => {
|
||||
it("picks the first upcoming node whose prerequisites are all done", () => {
|
||||
const { order } = topoLayout(chainTree);
|
||||
// a done, b current (not done), c upcoming but blocked by b → no next.
|
||||
assert.equal(nextNodeId(chainTree, order), null);
|
||||
|
||||
const unblocked: PlanTree = { ...chainTree, doneNodeIds: ["a", "b"], currentNodeId: null };
|
||||
assert.equal(nextNodeId(unblocked, order), "c");
|
||||
});
|
||||
|
||||
it("an independent root is next as soon as it is upcoming", () => {
|
||||
const tree: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "x", title: "X" },
|
||||
{ id: "y", title: "Y" },
|
||||
],
|
||||
edges: [],
|
||||
currentNodeId: "x",
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
assert.equal(nextNodeId(tree, topoLayout(tree).order), "y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TreePanel rendering", () => {
|
||||
it("renders nothing before any tree exists (probe phase) — and for a failed first init", () => {
|
||||
assert.equal(renderToStaticMarkup(<TreePanel messages={[]} />), "");
|
||||
const probing = asMessages([
|
||||
{ parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] },
|
||||
]);
|
||||
assert.equal(renderToStaticMarkup(<TreePanel messages={probing} />), "");
|
||||
|
||||
const failedInit = asMessages([{ parts: [planPart("plan_init", { ...chainTree, nodes: [] }, false)] }]);
|
||||
assert.equal(renderToStaticMarkup(<TreePanel messages={failedInit} />), "", "empty tree → no panel");
|
||||
});
|
||||
|
||||
it("renders the panel with nodes in topological order, statuses, and progress", () => {
|
||||
const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]);
|
||||
const html = renderToStaticMarkup(<TreePanel messages={messages} />);
|
||||
assert.ok(html.includes('data-testid="tree-panel"'), "the panel renders once a tree lands");
|
||||
|
||||
// Topological order in the markup (authored order was b, a, c).
|
||||
const ia = html.indexOf("Foundation");
|
||||
const ib = html.indexOf("Chain middle");
|
||||
const ic = html.indexOf("Summit");
|
||||
assert.ok(ia > -1 && ib > -1 && ic > -1);
|
||||
assert.ok(ia < ib && ib < ic, "prerequisites render before dependents");
|
||||
|
||||
// Per-node status from contracts.planNodeStatus.
|
||||
assert.ok(html.includes('data-status="done"'), "a: done");
|
||||
assert.ok(html.includes('data-status="current"'), "b: current");
|
||||
assert.ok(html.includes('data-status="upcoming"'), "c: upcoming");
|
||||
|
||||
// Current-position highlight + its summary surfaced (hover has it too).
|
||||
assert.ok(/data-status="current"[^>]*bg-primary\/10/.test(html), "current row is the highlighted one");
|
||||
assert.ok(html.includes("The middle step."), "current node's summary is visible");
|
||||
|
||||
// Progress counter in the header.
|
||||
assert.match(html, /1\/3/);
|
||||
});
|
||||
|
||||
it("dependency depth indents dependents (marginLeft grows with depth)", () => {
|
||||
const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]);
|
||||
const html = renderToStaticMarkup(<TreePanel messages={messages} />);
|
||||
assert.ok(html.includes("margin-left:0"), "root at depth 0");
|
||||
assert.ok(html.includes("margin-left:12px"), "depth 1");
|
||||
assert.ok(html.includes("margin-left:24px"), "depth 2");
|
||||
});
|
||||
|
||||
it("node summaries are available on hover (title attribute)", () => {
|
||||
const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]);
|
||||
const html = renderToStaticMarkup(<TreePanel messages={messages} />);
|
||||
assert.ok(html.includes('title="Where it all starts."'), "done node's summary on hover");
|
||||
});
|
||||
|
||||
it("marks the next unlocked node (data-next + the quiet label)", () => {
|
||||
const tree: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "a", title: "Foundation" },
|
||||
{ id: "b", title: "Middle" },
|
||||
{ id: "c", title: "Summit" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: ["a"],
|
||||
version: 2,
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
<TreePanel messages={asMessages([{ parts: [planPart("plan_mark_done", tree)] }])} />,
|
||||
);
|
||||
// b's prerequisite (a) is done → b is next; c stays blocked.
|
||||
assert.ok(/data-testid="tree-node-b"[^>]*data-next="true"/.test(html), "b is the next node");
|
||||
assert.ok(!/data-testid="tree-node-c"[^>]*data-next/.test(html), "c is not next");
|
||||
assert.match(html, /next/, "the quiet next label");
|
||||
});
|
||||
|
||||
it("live updates: a later plan output replaces the earlier tree", () => {
|
||||
const init: PlanTree = {
|
||||
nodes: [{ id: "a", title: "Only node" }],
|
||||
edges: [],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
const before = renderToStaticMarkup(
|
||||
<TreePanel messages={asMessages([{ parts: [planPart("plan_init", init)] }])} />,
|
||||
);
|
||||
assert.ok(before.includes("Only node"));
|
||||
|
||||
const progressed: PlanTree = {
|
||||
...init,
|
||||
nodes: [
|
||||
{ id: "a", title: "Only node" },
|
||||
{ id: "z", title: "Detour" },
|
||||
],
|
||||
currentNodeId: "z",
|
||||
doneNodeIds: ["a"],
|
||||
version: 2,
|
||||
};
|
||||
const after = renderToStaticMarkup(
|
||||
<TreePanel
|
||||
messages={asMessages([
|
||||
{ parts: [planPart("plan_init", init), planPart("plan_add_node", progressed)] },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
assert.ok(after.includes("Detour"), "the new node appears");
|
||||
assert.ok(after.includes('data-status="current"'), "position moved");
|
||||
});
|
||||
|
||||
it("the collapsed rail: thin strip with the expand affordance and quiet progress", () => {
|
||||
const html = renderToStaticMarkup(<TreePanelRail doneCount={1} totalCount={3} onExpand={() => {}} />);
|
||||
assert.ok(html.includes('data-testid="tree-panel-rail"'));
|
||||
assert.ok(html.includes('aria-label="Show plan"'), "an accessible expand action");
|
||||
assert.match(html, /1\/3/, "progress survives the collapse");
|
||||
assert.ok(!html.includes("Foundation"), "no node content in the strip");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the graph (expanded view) ────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
TreeGraph,
|
||||
graphLayout,
|
||||
wrapTitle,
|
||||
NODE_H,
|
||||
NODE_W,
|
||||
} from "../components/tree-panel.tsx";
|
||||
|
||||
describe("wrapTitle (box labels)", () => {
|
||||
it("wraps to lines within the char budget", () => {
|
||||
assert.deepEqual(wrapTitle("Chain rule intuition for composites"), [
|
||||
"Chain rule intuition",
|
||||
"for composites",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a title that fits stays one line", () => {
|
||||
assert.deepEqual(wrapTitle("Foundation"), ["Foundation"]);
|
||||
});
|
||||
|
||||
it("overflowing titles end in an ellipsis", () => {
|
||||
const lines = wrapTitle("Substitution and the differential of implicit functions");
|
||||
assert.equal(lines.length, 2);
|
||||
assert.ok(lines.at(-1)?.endsWith("…"));
|
||||
});
|
||||
|
||||
it("a single oversized word is hard-clamped", () => {
|
||||
const lines = wrapTitle("antidifferentiation-techniques-overview");
|
||||
assert.ok(lines.every((l) => l.length <= 22));
|
||||
assert.ok(lines.at(-1)?.endsWith("…"));
|
||||
});
|
||||
|
||||
it("empty titles render one empty line (never an empty array)", () => {
|
||||
assert.deepEqual(wrapTitle(""), [""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("graphLayout (layered, top-down)", () => {
|
||||
it("one node per depth row, authoring order within the row, rows centered", () => {
|
||||
const { order, depth } = topoLayout(chainTree);
|
||||
const layout = graphLayout(order, depth);
|
||||
assert.equal(layout.height, NODE_H * 3 + 64 * 2, "three rows plus arrow gaps");
|
||||
// b, a: depth 0; c: depth 1 → wait: a is authored second but is the root.
|
||||
assert.equal(depth.get("a"), 0);
|
||||
assert.equal(depth.get("b"), 1);
|
||||
assert.equal(depth.get("c"), 2);
|
||||
// each row of one node is centered on the (single-column) width.
|
||||
assert.equal(layout.x.get("a"), 0);
|
||||
assert.equal(layout.x.get("b"), 0);
|
||||
assert.equal(layout.x.get("c"), 0);
|
||||
assert.equal(layout.y.get("a"), 0);
|
||||
assert.equal(layout.y.get("b"), NODE_H + 64);
|
||||
});
|
||||
|
||||
it("two same-depth nodes sit side by side, centered over the widest row", () => {
|
||||
const tree: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "r", title: "Root" },
|
||||
{ id: "x", title: "X" },
|
||||
{ id: "y", title: "Y" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "r", to: "x" },
|
||||
{ from: "r", to: "y" },
|
||||
],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
const { order, depth } = topoLayout(tree);
|
||||
const layout = graphLayout(order, depth);
|
||||
const xX = layout.x.get("x")!;
|
||||
const xY = layout.x.get("y")!;
|
||||
assert.equal(xY - xX, NODE_W + 28, "one box + one gap apart");
|
||||
// row 1 is the widest → row 0 centers over it: the root sits exactly
|
||||
// between the two dependents' centers (88 and 292 → 190 - 88 = 102).
|
||||
assert.equal(layout.x.get("r"), (2 * NODE_W + 28 - NODE_W) / 2, "the single root centers over the two-wide row");
|
||||
assert.equal(layout.width, 2 * NODE_W + 28);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TreeGraph rendering", () => {
|
||||
it("renders one box per node with its status, one arrow per edge", () => {
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
|
||||
for (const [id, status] of [
|
||||
["a", "done"],
|
||||
["b", "current"],
|
||||
["c", "upcoming"],
|
||||
] as const) {
|
||||
assert.ok(
|
||||
new RegExp(`data-testid="graph-node-${id}"[^>]*data-status="${status}"`).test(html),
|
||||
`${id} is ${status}`,
|
||||
);
|
||||
}
|
||||
assert.equal(html.match(/marker-end="url\(#lh-plan-arrow\)"/g)?.length, 2, "two edges, two arrows");
|
||||
});
|
||||
|
||||
it("colors progress: done boxes carry the teach-phase tint", () => {
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
|
||||
assert.match(html, /data-status="done"[\s\S]*?fill-phase-teach\/10/);
|
||||
assert.match(html, /data-status="current"[\s\S]*?stroke-primary/);
|
||||
});
|
||||
|
||||
it("marks the next unlocked node", () => {
|
||||
const tree: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "a", title: "Foundation" },
|
||||
{ id: "b", title: "Middle" },
|
||||
],
|
||||
edges: [{ from: "a", to: "b" }],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: ["a"],
|
||||
version: 2,
|
||||
};
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={tree} />);
|
||||
assert.match(html, /data-testid="graph-node-b"[^>]*data-next="true"/);
|
||||
});
|
||||
|
||||
it("node titles wrap inside the box (two lines max, ellipsis on overflow)", () => {
|
||||
const tree: PlanTree = {
|
||||
nodes: [{ id: "long", title: "Substitution and the differential of implicit functions" }],
|
||||
edges: [],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={tree} />);
|
||||
const texts = html.match(/<text[^>]*>([^<]*)<\/text>/g) ?? [];
|
||||
assert.equal(texts.length, 2, "two wrapped lines");
|
||||
assert.ok(texts.at(-1)?.includes("…"));
|
||||
});
|
||||
|
||||
it("hover shows title and summary", () => {
|
||||
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
|
||||
assert.ok(html.includes("Foundation — Where it all starts."));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── graph view math (pan lock at fit, zoom-to-cursor) ────────────────────────
|
||||
|
||||
import {
|
||||
fitGraphView,
|
||||
zoomedGraphView,
|
||||
type GraphView,
|
||||
} from "../components/tree-panel.tsx";
|
||||
|
||||
describe("fitGraphView / zoomedGraphView", () => {
|
||||
const box = { w: 1000, h: 800 };
|
||||
const viewport = { w: 300, h: 400 };
|
||||
|
||||
it("fit centers the whole graph in the viewport at the given zoom", () => {
|
||||
const v = fitGraphView(box, viewport, 0.3);
|
||||
assert.equal(v.zoom, 0.3);
|
||||
assert.equal(v.x, (300 - 1000 * 0.3) / 2);
|
||||
assert.equal(v.y, (400 - 800 * 0.3) / 2);
|
||||
});
|
||||
|
||||
it("zooming in moves the point under the cursor to stay put (zoom-to-cursor)", () => {
|
||||
const v: GraphView = { zoom: 0.5, x: -100, y: -50 };
|
||||
// world point at cursor (50, 60) pre-zoom: (50 - (-100))/0.5 = 300
|
||||
const next = zoomedGraphView(v, 2, 0.1, { x: 50, y: 60 }, box, viewport);
|
||||
assert.equal(next.zoom, 1);
|
||||
// the same world point maps back to the cursor: (50 - next.x)/1 = 300
|
||||
assert.equal(50 - next.x, 300);
|
||||
assert.equal(60 - next.y, 220);
|
||||
});
|
||||
|
||||
it("zooming out past the floor SNAPS to the centered fit (fully zoomed out = the fit)", () => {
|
||||
const v: GraphView = { zoom: 1.8, x: -900, y: -400 };
|
||||
const next = zoomedGraphView(v, 1 / 8, 0.3, { x: 100, y: 100 }, box, viewport);
|
||||
assert.equal(next.zoom, 0.3, "clamped at the floor");
|
||||
assert.deepEqual(next, fitGraphView(box, viewport, 0.3), "and re-centered: nothing to pan");
|
||||
});
|
||||
|
||||
it("a no-op zoom returns the same view (no drift)", () => {
|
||||
const v: GraphView = { zoom: 2, x: 10, y: 10 };
|
||||
// factor 1 → same zoom; at MAX already → clamped to same → same view
|
||||
assert.equal(zoomedGraphView(v, 1, 0.3, { x: 0, y: 0 }, box, viewport), v);
|
||||
});
|
||||
|
||||
it("zoom-in clamps at the ceiling (2×)", () => {
|
||||
const v: GraphView = { zoom: 1.9, x: 0, y: 0 };
|
||||
const next = zoomedGraphView(v, 3, 0.3, { x: 10, y: 10 }, box, viewport);
|
||||
assert.equal(next.zoom, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fit sizing (wave-2: small graphs must USE the panel, not float tiny) ─────
|
||||
|
||||
import { fitZoomFor } from "../components/tree-panel.tsx";
|
||||
|
||||
describe("fitZoomFor", () => {
|
||||
it("a graph smaller than the panel scales UP to use it — no tiny diagram with empty margins", () => {
|
||||
// 5-node chain: 176 wide × 448 tall in a 384×600 panel: height-bound.
|
||||
// Old bug: the ≤1× cap rendered such graphs at natural size, centered,
|
||||
// tiny, with big empty areas above and below.
|
||||
const z = fitZoomFor({ w: 176, h: 448 }, { w: 384, h: 600 });
|
||||
assert.equal(z, (600 - 16) / 448, "height-bound: fills the tall panel");
|
||||
assert.ok(z > 1, "scales UP past 1×");
|
||||
});
|
||||
|
||||
it("the scale-up is capped: a lone node doesn't become a billboard", () => {
|
||||
const z = fitZoomFor({ w: 176, h: 48 }, { w: 384, h: 600 });
|
||||
assert.equal(z, 1.5, "capped at FIT_ZOOM_MAX");
|
||||
});
|
||||
|
||||
it("a wide graph is width-bound; a tall graph is height-bound", () => {
|
||||
assert.equal(fitZoomFor({ w: 1000, h: 100 }, { w: 384, h: 600 }), (384 - 16) / 1000);
|
||||
assert.equal(fitZoomFor({ w: 100, h: 1000 }, { w: 384, h: 600 }), (600 - 16) / 1000);
|
||||
});
|
||||
|
||||
it("a graph larger than the panel scales DOWN, floor-clamped", () => {
|
||||
assert.equal(fitZoomFor({ w: 1500, h: 1500 }, { w: 384, h: 600 }), (384 - 16) / 1500);
|
||||
// absurdly large → clamped at the floor, never disappears
|
||||
assert.equal(fitZoomFor({ w: 5000, h: 5000 }, { w: 384, h: 600 }), 0.15);
|
||||
assert.equal(fitZoomFor({ w: 1e6, h: 1e6 }, { w: 384, h: 600 }), 0.15);
|
||||
});
|
||||
|
||||
it("an unmeasured viewport (SSR / first paint) stays at 1×", () => {
|
||||
assert.equal(fitZoomFor({ w: 1000, h: 800 }, { w: 0, h: 0 }), 1);
|
||||
assert.equal(fitZoomFor({ w: 0, h: 800 }, { w: 384, h: 600 }), 1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user