Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import type { DashboardAgentStat } from "../../lib/api";
import { OverviewAgentDistribution } from "./overview-agent-distribution";

const perAgent: DashboardAgentStat[] = [
{
name: "codex",
displayName: "Codex",
icon: "/icon/agent/codex.svg",
sessions: 3,
messages: 30,
tokens: 3_000,
cost: 4,
},
{
name: "claudecode",
displayName: "Claude Code",
icon: "/icon/agent/claudecode.svg",
sessions: 2,
messages: 20,
tokens: 2_000,
cost: 2,
},
];

afterEach(cleanup);

describe("OverviewAgentDistribution", () => {
it("browses agent columns and announces their values from the keyboard", () => {
render(<OverviewAgentDistribution perAgent={perAgent} />);
const region = screen.getByRole("region", { name: "Agents" });
const chart = within(region).getByRole("listbox", { name: "Agent distribution chart" });
const options = within(chart).getAllByRole("option");
const liveSummary = within(region).getByRole("status");

fireEvent.focus(options[0]!);
expect(liveSummary.textContent).toBe("Codex: $4.00, 3 sessions");

fireEvent.keyDown(options[0]!, { key: "ArrowRight" });
expect(document.activeElement).toBe(options[1]);
expect(liveSummary.textContent).toBe("Claude Code: $2.00, 2 sessions");
});
});
13 changes: 10 additions & 3 deletions apps/web/src/components/overview/overview-agent-distribution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,23 @@ const BAR_COLORS = ["var(--brand)"];
export function OverviewAgentDistribution({ perAgent }: { perAgent: DashboardAgentStat[] }) {
const [hover, setHover] = useState<BarHover | null>(null);

const { byCost, visible, values, axisMax } = useMemo(() => {
const { byCost, visible, values, axisMax, itemLabels } = useMemo(() => {
const byCost = perAgent.some((agent) => agent.cost > 0);
const weightOf = (agent: DashboardAgentStat) => (byCost ? agent.cost : agent.sessions);
const visible = [...perAgent].sort((a, b) => weightOf(b) - weightOf(a)).slice(0, AGENT_LIMIT);
const values = visible.map((agent) => [weightOf(agent)]);
const itemLabels = visible.map((agent) =>
byCost
? `${agent.displayName}: ${formatUsd(agent.cost)}, ${formatInt(agent.sessions)} sessions`
: `${agent.displayName}: ${formatInt(agent.sessions)} sessions`,
);
// The leader touches the top: with the figures printed under every bar
// there is no axis to round to, and rounded headroom would just be blank.
return { byCost, visible, values, axisMax: Math.max(...values.flat(), 0) };
return { byCost, visible, values, axisMax: Math.max(...values.flat(), 0), itemLabels };
}, [perAgent]);

return (
<Panel className="p-4">
<Panel role="region" aria-label="Agents" className="p-4">
<PanelHeader
title="Agents"
meta={`${byCost ? "by cost" : "by sessions"} · ${perAgent.length} total`}
Expand All @@ -50,6 +55,8 @@ export function OverviewAgentDistribution({ perAgent }: { perAgent: DashboardAge
onHover={setHover}
layout={BAR_LAYOUT}
height={CHART_HEIGHT}
ariaLabel="Agent distribution chart"
itemLabels={itemLabels}
/>
<div
className="mt-2 grid"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { DashboardTotals, ModelCostEntry, ModelDistributionEntry } from "../../lib/api";
import { OverviewCostBreakdown } from "./overview-cost-breakdown";
Expand Down Expand Up @@ -48,6 +48,27 @@ describe("OverviewCostBreakdown", () => {
expect(screen.getByText("$10.00")).toBeTruthy();
expect(screen.getByText("80%")).toBeTruthy();
expect(screen.getByText("20%")).toBeTruthy();
expect(screen.getByRole("region", { name: "Cost by Model" })).toBeTruthy();
});

it("browses model slices and announces their values from the keyboard", () => {
render(
<OverviewCostBreakdown
modelCost={modelCost}
modelDistribution={modelDistribution}
totals={totals()}
/>,
);
const chart = screen.getByRole("listbox", { name: "Cost by Model chart" });
const options = within(chart).getAllByRole("option");
const liveSummary = screen.getByRole("status");

fireEvent.focus(options[0]!);
expect(liveSummary.textContent).toBe("sonnet: 80%, $8.00");

fireEvent.keyDown(options[0]!, { key: "ArrowRight" });
expect(document.activeElement).toBe(options[1]);
expect(liveSummary.textContent).toBe("haiku: 20%, $2.00");
});

it("collapses a four-figure total so it fits the ring", () => {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/overview/overview-cost-breakdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,13 @@ export function OverviewCostBreakdown({
);

const title = byCost ? "Cost by Model" : "Models";
const itemLabels = entries.map(
(entry) =>
`${entry.label}: ${formatPercent(ringTotal > 0 ? entry.value / ringTotal : 0)}, ${entry.display}`,
);

return (
<Panel className="p-4">
<Panel role="region" aria-label={title} className="p-4">
<PanelHeader title={title} meta={byCost ? "by cost" : "by tokens"} />

{entries.length === 0 ? (
Expand All @@ -108,6 +112,8 @@ export function OverviewCostBreakdown({
hovered={hovered}
onHover={setHovered}
size={DONUT_SIZE}
ariaLabel={`${title} chart`}
itemLabels={itemLabels}
>
<span className="console-mono text-[16px] font-semibold text-[var(--console-text)]">
{byCost ? formatUsdCompact(totals.cost) : formatCompact(totals.tokens)}
Expand Down
24 changes: 23 additions & 1 deletion apps/web/src/components/overview/overview-usage-chart.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { DashboardDailyBucket } from "../../lib/api";
import { OverviewUsageChart } from "./overview-usage-chart";
Expand Down Expand Up @@ -62,4 +62,26 @@ describe("OverviewUsageChart", () => {
expect(screen.getByText("No usage data")).toBeTruthy();
expect(screen.queryByText("Daily cost")).toBeNull();
});

it("moves the tooltip and live summary with the keyboard", () => {
render(<OverviewUsageChart daily={daily} />);
const chart = screen.getByRole("listbox", { name: "Daily usage chart" });
const options = within(chart).getAllByRole("option");
const liveSummary = screen.getByRole("status");
expect(options.map((option) => option.tabIndex)).toEqual([0, -1]);

fireEvent.focus(options[0]!);
expect(screen.getByText("2 sessions · 30 messages")).toBeTruthy();
expect(liveSummary.textContent).toContain("01-01: 1.0k tokens");

fireEvent.keyDown(options[0]!, { key: "ArrowRight" });
expect(document.activeElement).toBe(options[1]);
expect(options.map((option) => option.tabIndex)).toEqual([-1, 0]);
expect(screen.getByText("3 sessions · 45 messages")).toBeTruthy();
expect(liveSummary.textContent).toContain("01-02: 100 tokens");

fireEvent.keyDown(options[1]!, { key: "Escape" });
expect(screen.queryByText("3 sessions · 45 messages")).toBeNull();
expect(liveSummary.textContent).toBe("");
});
});
7 changes: 7 additions & 0 deletions apps/web/src/components/overview/overview-usage-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ function bucketTokens(bucket: DashboardDailyBucket): number {
return bucket.input + bucket.output + bucket.cache_read + bucket.cache_create;
}

function bucketSummary(bucket: DashboardDailyBucket): string {
return `${formatMonthDay(bucket.date)}: ${formatCompact(bucketTokens(bucket))} tokens, ${formatUsd(bucket.cost)}, ${formatInt(bucket.sessions)} sessions, ${formatInt(bucket.messages)} messages, input ${formatCompact(bucket.input)}, output ${formatCompact(bucket.output)}, cache read ${formatCompact(bucket.cache_read)}, cache write ${formatCompact(bucket.cache_create)}`;
}

function CostArea({ daily }: { daily: DashboardDailyBucket[] }) {
const costs = useMemo(() => daily.map((bucket) => bucket.cost), [daily]);
const labels = useMemo(() => daily.map((bucket) => formatMonthDay(bucket.date)), [daily]);
Expand Down Expand Up @@ -112,6 +116,7 @@ export function OverviewUsageChart({ daily }: { daily: DashboardDailyBucket[] })
() => daily.map((bucket) => TOKEN_SERIES.map((series) => bucket[series.key])),
[daily],
);
const itemLabels = useMemo(() => daily.map(bucketSummary), [daily]);
const axisMax = niceMax(daily.reduce((peak, bucket) => Math.max(peak, bucketTokens(bucket)), 0));

const first = daily[0];
Expand Down Expand Up @@ -164,6 +169,8 @@ export function OverviewUsageChart({ daily }: { daily: DashboardDailyBucket[] })
layout={BAR_LAYOUT}
height={BAR_HEIGHT}
formatTick={formatCompact}
ariaLabel="Daily usage chart"
itemLabels={itemLabels}
/>
{hovered && hover ? (
<div className="absolute inset-x-0 top-0" style={{ left: TILE_AXIS_WIDTH }}>
Expand Down
88 changes: 88 additions & 0 deletions apps/web/src/components/ui/chart-keyboard-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useRef, useState, type KeyboardEvent } from "react";

import { cn } from "../../lib/utils";

function nextItemIndex(key: string, index: number, lastIndex: number): number | null {
if (key === "ArrowLeft") return Math.max(0, index - 1);
if (key === "ArrowRight") return Math.min(lastIndex, index + 1);
if (key === "Home") return 0;
if (key === "End") return lastIndex;
return null;
}

export function ChartKeyboardList({
label,
itemLabels,
activeIndex,
onActiveIndexChange,
layout,
}: {
label: string;
itemLabels: readonly string[];
activeIndex: number | null;
onActiveIndexChange: (index: number | null) => void;
layout: "columns" | "surface";
}) {
const itemRefs = useRef<Array<HTMLDivElement | null>>([]);
const [rovingIndex, setRovingIndex] = useState(0);
const lastIndex = itemLabels.length - 1;
const tabIndex = Math.min(rovingIndex, Math.max(0, lastIndex));

const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>, index: number) => {
if (event.key === "Escape") {
event.preventDefault();
onActiveIndexChange(null);
return;
}
const nextIndex = nextItemIndex(event.key, index, lastIndex);
if (nextIndex === null) return;

event.preventDefault();
setRovingIndex(nextIndex);
itemRefs.current[nextIndex]?.focus();
};

return (
<>
<div
role="listbox"
aria-label={label}
aria-orientation="horizontal"
className={cn(
"pointer-events-none absolute inset-0 z-[1]",
layout === "columns" ? "flex" : null,
)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) onActiveIndexChange(null);
}}
>
{itemLabels.map((itemLabel, index) => (
<div
key={index}
ref={(element) => {
itemRefs.current[index] = element;
}}
role="option"
aria-label={itemLabel}
aria-selected={activeIndex === index}
tabIndex={index === tabIndex ? 0 : -1}
className={cn(
"pointer-events-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--brand)]",
layout === "columns"
? "h-full min-w-0 flex-1 rounded-sm"
: "absolute inset-0 rounded-full",
)}
onFocus={() => {
setRovingIndex(index);
onActiveIndexChange(index);
}}
onKeyDown={(event) => handleKeyDown(event, index)}
/>
))}
</div>
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{activeIndex === null ? "" : itemLabels[activeIndex]}
</span>
</>
);
}
12 changes: 12 additions & 0 deletions apps/web/src/components/ui/tile-bar-plot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "../../hooks/useBarField";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { cn } from "../../lib/utils";
import { ChartKeyboardList } from "./chart-keyboard-list";

const TICK_FRACTIONS = [1, 0.75, 0.5, 0.25, 0] as const;

Expand All @@ -31,6 +32,8 @@ export function TileBarPlot({
layout,
height,
formatTick,
ariaLabel,
itemLabels,
className,
}: {
/** `[column][band]`; a plain bar chart is one band per column. */
Expand All @@ -44,6 +47,8 @@ export function TileBarPlot({
height: number;
/** Provide to render a value axis on the left; omit for a bare plot. */
formatTick?: (value: number) => string;
ariaLabel: string;
itemLabels: readonly string[];
className?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
Expand Down Expand Up @@ -94,6 +99,13 @@ export function TileBarPlot({
/>
))}
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 block" />
<ChartKeyboardList
label={ariaLabel}
itemLabels={itemLabels}
activeIndex={hovered?.column ?? null}
onActiveIndexChange={(column) => onHover(column === null ? null : { column, band: null })}
layout="columns"
/>
</div>
</div>
);
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/ui/tile-donut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import { useRef, type ReactNode } from "react";

import { useDonutRing } from "../../hooks/useDonutRing";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { ChartKeyboardList } from "./chart-keyboard-list";

export function TileDonut({
shares,
colors,
hovered,
onHover,
size,
ariaLabel,
itemLabels,
children,
}: {
/** Fractions of the ring, in draw order; they should sum to 1. */
Expand All @@ -21,6 +24,8 @@ export function TileDonut({
hovered: number | null;
onHover: (index: number | null) => void;
size: number;
ariaLabel: string;
itemLabels: readonly string[];
/** Centre content, e.g. the total. */
children?: ReactNode;
}) {
Expand All @@ -39,6 +44,13 @@ export function TileDonut({
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
{children}
</div>
<ChartKeyboardList
label={ariaLabel}
itemLabels={itemLabels}
activeIndex={hovered}
onActiveIndexChange={onHover}
layout="surface"
/>
</div>
);
}
Loading