Skip to content

Commit 696859a

Browse files
aksOpsclaude
andcommitted
feat(ui): tool-frequency sparkline on SessionCard (V7)
~80×14 inline SVG histogram showing tool_call density over the last 10 min, 20 × 30-second buckets. Makes "busy vs quiet" readable at a glance, complementary to the ⏵ tool-call-recency reading. Client-side: pulls from the existing feed cache (useFeed hook + SseProvider fan-out), so zero backend change. Fresh sessions with no cached events render nothing — keeps the card clean. Bars scale to the in-view peak (not a fixed max), so a trickle of activity still shows meaningful height. Bars `fill-fg-muted/70` — visible without competing with the mode badge or token blips. Mount point: end of the token-blip row on the card. Tests: - vitest: sparkline.test.ts covers bucketize edge cases (empty, inside, outside window, clamp at now, non-positive opts). +component test asserts SVG renders with 20 rects when cache has entries, nothing when empty. - playwright: dashboard.spec.ts seeds 6 tool_call events over the SSE mock once and asserts the sparkline SVG becomes visible on the card. `make regression` green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 748cb36 commit 696859a

7 files changed

Lines changed: 233 additions & 3 deletions

File tree

ui/e2e/dashboard.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,44 @@ test.describe("Dashboard", () => {
6565
await expect(fill).toHaveAttribute("style", /width:\s*95%/);
6666
});
6767

68+
test("renders the tool-frequency sparkline when the feed has events", async ({
69+
page,
70+
}) => {
71+
// Seed the SSE stream with 6 tool_call events spread across 10 min.
72+
// The sparkline component reads from the SseProvider-populated feed
73+
// cache, so we route /events/all to deliver synthetic events once.
74+
const now = Date.now();
75+
const lines: string[] = [];
76+
for (let i = 0; i < 6; i++) {
77+
const ts = new Date(now - i * 60_000).toISOString();
78+
const id = `${now - i * 60_000}000000-0`;
79+
const data = JSON.stringify({
80+
session: "alpha",
81+
tool: "Bash",
82+
input: "",
83+
is_error: false,
84+
ts,
85+
});
86+
lines.push(`id: ${id}\nevent: tool_call\ndata: ${data}\n\n`);
87+
}
88+
await page.route(
89+
"**/events/all",
90+
(route) =>
91+
route.fulfill({
92+
status: 200,
93+
contentType: "text/event-stream",
94+
body: ": ok\n\n" + lines.join(""),
95+
}),
96+
{ times: 1 },
97+
);
98+
await page.goto("/");
99+
const card = page.getByRole("link", { name: /alpha/i }).first();
100+
const svg = card.locator(
101+
'svg[aria-label*="tool call frequency" i]',
102+
);
103+
await expect(svg).toBeVisible();
104+
});
105+
68106
test("renders the stale chip when tool call is older than 30 min", async ({
69107
page,
70108
}) => {

ui/src/components/SessionCard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Link } from "react-router";
22
import { isStale, type Session } from "@/hooks/useSessions";
33
import { HealthDot, healthState } from "@/components/HealthDot";
44
import { AttentionLabel } from "@/components/AttentionLabel";
5+
import { ToolFrequencySparkline } from "@/components/ToolFrequencySparkline";
56
import { compactNumber, relativeTime, shortenPath } from "@/lib/format";
67
import { pctColor } from "@/lib/quota";
78
import { cn } from "@/lib/utils";
@@ -141,6 +142,7 @@ export function SessionCard({ session, active }: SessionCardProps) {
141142
color="var(--accent-gold)"
142143
value={session.tokens.cache_tokens}
143144
/>
145+
<ToolFrequencySparkline sessionName={session.name} />
144146
</div>
145147
)}
146148

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "vitest";
2+
import { render } from "@testing-library/react";
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4+
import { ToolFrequencySparkline } from "./ToolFrequencySparkline";
5+
import type { ToolCallRow } from "@/hooks/useFeed";
6+
7+
function renderWithFeed(sessionName: string, events: ToolCallRow[]) {
8+
const qc = new QueryClient({
9+
defaultOptions: { queries: { retry: false } },
10+
});
11+
qc.setQueryData<ToolCallRow[]>(["feed", sessionName], events);
12+
return render(
13+
<QueryClientProvider client={qc}>
14+
<ToolFrequencySparkline sessionName={sessionName} />
15+
</QueryClientProvider>,
16+
);
17+
}
18+
19+
function tc(ts: string, tool = "Bash"): ToolCallRow {
20+
return { session: "s", tool, input: "", is_error: false, ts };
21+
}
22+
23+
describe("ToolFrequencySparkline", () => {
24+
it("renders nothing when the feed cache is empty", () => {
25+
const { container } = renderWithFeed("s", []);
26+
expect(container.querySelector("svg")).toBeNull();
27+
});
28+
29+
it("renders a 20-rect SVG when there is cached activity", () => {
30+
const events: ToolCallRow[] = [];
31+
for (let i = 0; i < 5; i++) {
32+
events.push(tc(new Date(Date.now() - i * 60_000).toISOString()));
33+
}
34+
const { container } = renderWithFeed("s", events);
35+
const svg = container.querySelector("svg");
36+
expect(svg).not.toBeNull();
37+
expect(svg!.querySelectorAll("rect").length).toBe(20);
38+
});
39+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useMemo } from "react";
2+
import { useFeed } from "@/hooks/useFeed";
3+
import { bucketize } from "@/lib/sparkline";
4+
5+
const WIDTH = 80;
6+
const HEIGHT = 14;
7+
const BUCKETS = 20;
8+
const WINDOW_MS = 10 * 60 * 1000; // 10 minutes
9+
10+
interface Props {
11+
sessionName: string;
12+
}
13+
14+
/**
15+
* Inline ~80×14 SVG histogram of tool-call frequency over the last
16+
* 10 min for one session. Pulls from the existing feed cache — zero
17+
* backend contract. Renders nothing when the session has no cached
18+
* tool calls (keeps fresh cards clean).
19+
*/
20+
export function ToolFrequencySparkline({ sessionName }: Props) {
21+
const { data } = useFeed(sessionName);
22+
23+
const bars = useMemo(() => {
24+
if (!data || data.length === 0) return null;
25+
const now = Date.now();
26+
const timestamps = data
27+
.map((row) => Date.parse(row.ts))
28+
.filter((t) => !Number.isNaN(t));
29+
const counts = bucketize(timestamps, {
30+
now,
31+
windowMs: WINDOW_MS,
32+
buckets: BUCKETS,
33+
});
34+
const peak = Math.max(1, ...counts);
35+
const barW = WIDTH / BUCKETS;
36+
return counts.map((c, i) => {
37+
const h = (c / peak) * HEIGHT;
38+
return (
39+
<rect
40+
key={i}
41+
x={i * barW}
42+
y={HEIGHT - h}
43+
width={Math.max(0, barW - 1)}
44+
height={h}
45+
className="fill-fg-muted/70"
46+
/>
47+
);
48+
});
49+
}, [data]);
50+
51+
if (!bars) return null;
52+
return (
53+
<svg
54+
width={WIDTH}
55+
height={HEIGHT}
56+
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
57+
role="img"
58+
aria-label={`${sessionName} tool call frequency, last 10 min`}
59+
className="shrink-0"
60+
>
61+
{bars}
62+
</svg>
63+
);
64+
}

ui/src/lib/sparkline.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from "vitest";
2+
import { bucketize } from "./sparkline";
3+
4+
const opts = { now: 1_000_000, windowMs: 10_000, buckets: 10 };
5+
6+
describe("bucketize", () => {
7+
it("returns a zero-filled array for empty input", () => {
8+
expect(bucketize([], opts)).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
9+
});
10+
11+
it("counts events in the right buckets (oldest-first)", () => {
12+
const oldest = opts.now - opts.windowMs;
13+
const ts = [oldest + 500, oldest + 1_500, oldest + 9_500];
14+
// bucketMs = 1000 → idx 0, 1, 9
15+
expect(bucketize(ts, opts)).toEqual([1, 1, 0, 0, 0, 0, 0, 0, 0, 1]);
16+
});
17+
18+
it("drops events outside the window", () => {
19+
expect(
20+
bucketize([opts.now - opts.windowMs - 1, opts.now + 1], opts),
21+
).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
22+
});
23+
24+
it("clamps an event at now into the last bucket", () => {
25+
const out = bucketize([opts.now], opts);
26+
expect(out[9]).toBe(1);
27+
});
28+
29+
it("returns empty for non-positive buckets or windowMs", () => {
30+
expect(bucketize([1, 2], { now: 0, windowMs: 0, buckets: 5 })).toEqual([]);
31+
expect(bucketize([1, 2], { now: 0, windowMs: 10, buckets: 0 })).toEqual([]);
32+
});
33+
});

ui/src/lib/sparkline.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Pure bucketing helper for tool-frequency sparklines. Kept framework-
3+
* agnostic so it's trivially unit-testable and reusable if another
4+
* surface wants the same histogram.
5+
*/
6+
7+
export interface BucketOptions {
8+
now: number;
9+
windowMs: number;
10+
buckets: number;
11+
}
12+
13+
/**
14+
* Count entries per bucket. Events older than (now - windowMs) are
15+
* dropped. Events at/after now are counted in the latest bucket. The
16+
* returned array has `buckets` entries ordered oldest-first.
17+
*/
18+
export function bucketize(
19+
timestamps: number[],
20+
{ now, windowMs, buckets }: BucketOptions,
21+
): number[] {
22+
if (buckets <= 0 || windowMs <= 0) return [];
23+
const out = new Array<number>(buckets).fill(0);
24+
const bucketMs = windowMs / buckets;
25+
const oldest = now - windowMs;
26+
for (const t of timestamps) {
27+
if (t < oldest || t > now) continue;
28+
const offset = t - oldest;
29+
let idx = Math.floor(offset / bucketMs);
30+
if (idx >= buckets) idx = buckets - 1;
31+
if (idx < 0) idx = 0;
32+
out[idx] += 1;
33+
}
34+
return out;
35+
}

ui/src/routes/DoctorPanel.test.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import { MemoryRouter } from "react-router";
55
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6+
import { ThemeProvider } from "@/hooks/useTheme";
67
import { DoctorPanel } from "@/routes/DoctorPanel";
78
import { TOKEN_KEY } from "@/lib/api";
89

@@ -19,9 +20,11 @@ function renderPanel() {
1920
});
2021
return render(
2122
<QueryClientProvider client={qc}>
22-
<MemoryRouter>
23-
<DoctorPanel />
24-
</MemoryRouter>
23+
<ThemeProvider>
24+
<MemoryRouter>
25+
<DoctorPanel />
26+
</MemoryRouter>
27+
</ThemeProvider>
2528
</QueryClientProvider>,
2629
);
2730
}
@@ -32,6 +35,22 @@ describe("DoctorPanel", () => {
3235
beforeEach(() => {
3336
originalFetch = globalThis.fetch;
3437
localStorage.setItem(TOKEN_KEY, "test-token");
38+
// JSDOM does not implement matchMedia; ThemeProvider needs it.
39+
if (typeof window.matchMedia !== "function") {
40+
Object.defineProperty(window, "matchMedia", {
41+
writable: true,
42+
value: (query: string) => ({
43+
matches: false,
44+
media: query,
45+
onchange: null,
46+
addListener: vi.fn(),
47+
removeListener: vi.fn(),
48+
addEventListener: vi.fn(),
49+
removeEventListener: vi.fn(),
50+
dispatchEvent: vi.fn(),
51+
}),
52+
});
53+
}
3554
});
3655

3756
afterEach(() => {

0 commit comments

Comments
 (0)