topology compact agent token cards

This commit is contained in:
mvschwarz committed 2026-05-07 01:09:05 -07:00
1 parent 714932c491
commit b53849c044
11 files changed
+213 -30

No files matched your search

@@ -33,6 +33,8 @@ interface RFNodeData {
contextUsedPercentage: number | null;
contextFresh: boolean;
contextAvailability: string;
contextTotalInputTokens: number | null;
contextTotalOutputTokens: number | null;
agentActivity?: AgentActivity | null;
currentQitems?: CurrentQitemSummary[];
}
@@ -67,6 +69,8 @@ export interface InventoryOverlay {
contextUsedPercentage?: number | null;
contextFresh?: boolean;
contextAvailability?: string;
contextTotalInputTokens?: number | null;
contextTotalOutputTokens?: number | null;
agentActivity?: AgentActivity | null;
currentQitems?: CurrentQitemSummary[];
}
@@ -125,6 +129,8 @@ export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: Inven
contextUsedPercentage: overlay?.contextUsedPercentage ?? null,
contextFresh: overlay?.contextFresh ?? false,
contextAvailability: overlay?.contextAvailability ?? "unknown",
contextTotalInputTokens: overlay?.contextTotalInputTokens ?? null,
contextTotalOutputTokens: overlay?.contextTotalOutputTokens ?? null,
agentActivity: overlay?.agentActivity ?? null,
currentQitems: overlay?.currentQitems ?? [],
},
@@ -160,6 +166,8 @@ export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: Inven
contextUsedPercentage: null,
contextFresh: false,
contextAvailability: "unknown",
contextTotalInputTokens: null,
contextTotalOutputTokens: null,
agentActivity: null,
currentQitems: [],
},
+2
View File
@@ -231,6 +231,8 @@ rigsRoutes.get("/:id/graph", async (c) => {
contextUsedPercentage: n.contextUsage?.usedPercentage ?? null,
contextFresh: n.contextUsage?.fresh ?? false,
contextAvailability: n.contextUsage?.availability ?? "unknown",
contextTotalInputTokens: n.contextUsage?.totalInputTokens ?? null,
contextTotalOutputTokens: n.contextUsage?.totalOutputTokens ?? null,
agentActivity: n.agentActivity ?? null,
currentQitems: n.canonicalSessionName
? currentQitemsBySession.get(n.canonicalSessionName) ?? []
@@ -88,6 +88,8 @@ describe("Context Usage Projection", () => {
const graphNode = body.nodes.find((n: any) => n.data?.logicalId === "dev.impl");
expect(graphNode?.data?.contextAvailability).toBe("known");
expect(graphNode?.data?.contextUsedPercentage).toBe(67);
expect(graphNode?.data?.contextTotalInputTokens).toBe(120000);
expect(graphNode?.data?.contextTotalOutputTokens).toBe(14000);
});
// T5: WhoamiResult includes contextUsage (via route)
@@ -78,6 +78,27 @@ describe("PL-019 projectRigToGraph: agentActivity + currentQitems", () => {
expect(result.nodes[0].data.agentActivity).toEqual(RUNNING_ACTIVITY);
});
it("projects context token totals onto RFNodeData when overlay carries them", () => {
const input = makeRig(
[{ id: "n1", logicalId: "alpha", role: "worker" }],
[],
[makeRunningSession("n1")]
);
const overlay: InventoryOverlay[] = [
{
logicalId: "alpha",
startupStatus: "ready",
canonicalSessionName: "r01-alpha",
restoreOutcome: "n-a",
contextTotalInputTokens: 120_000,
contextTotalOutputTokens: 14_000,
},
];
const result = projectRigToGraph(input, overlay);
expect(result.nodes[0].data.contextTotalInputTokens).toBe(120_000);
expect(result.nodes[0].data.contextTotalOutputTokens).toBe(14_000);
});
it("defaults agentActivity to null when overlay omits it", () => {
const input = makeRig(
[{ id: "n1", logicalId: "alpha", role: "worker" }],
@@ -155,6 +176,8 @@ describe("PL-019 projectRigToGraph: agentActivity + currentQitems", () => {
expect(podNode).toBeDefined();
expect(podNode!.data.agentActivity).toBeNull();
expect(podNode!.data.currentQitems).toEqual([]);
expect(podNode!.data.contextTotalInputTokens).toBeNull();
expect(podNode!.data.contextTotalOutputTokens).toBeNull();
});
it("multiple nodes with mixed activity states project independently", () => {
+38 -21
View File
@@ -17,6 +17,7 @@ import { ContextUsageRing } from "./ContextUsageRing.js";
import { ActivityRing } from "./topology/ActivityRing.js";
import { getActivityCardClasses, getActivityCardSignal } from "./topology/activity-card-visuals.js";
import type { TopologyActivityVisual } from "../lib/topology-activity.js";
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../lib/token-format.js";
interface RigNodeData {
logicalId: string;
@@ -42,6 +43,8 @@ interface RigNodeData {
contextUsedPercentage?: number | null;
contextFresh?: boolean;
contextAvailability?: string;
contextTotalInputTokens?: number | null;
contextTotalOutputTokens?: number | null;
placementState?: "available" | "selected" | null;
// PL-019: agent activity drives the node's primary "is this agent
// working?" dot color (replacing the previous startup-status color).
@@ -96,6 +99,9 @@ export function RigNode({ data }: { data: RigNodeData }) {
const activityAnimClass = getActivityAnimationClass(activityState);
const activityIsStale = isActivityStale(data.agentActivity);
const activityCard = getActivityCardSignal({ activityRing: data.activityRing, activityState });
const tokenTotal = sumTokenCounts(data.contextTotalInputTokens, data.contextTotalOutputTokens);
const tokenLabel = formatCompactTokenCount(tokenTotal);
const tokenTitle = formatTokenTotalTitle(data.contextTotalInputTokens, data.contextTotalOutputTokens);
const placementChipLabel = data.placementState === "selected" ? "target" : data.placementState === "available" ? "avail" : null;
@@ -114,6 +120,7 @@ export function RigNode({ data }: { data: RigNodeData }) {
data.resolvedSpecName ? `Spec: ${data.resolvedSpecName}` : null,
data.profile ? `Profile: ${data.profile}` : null,
typeof data.edgeCount === "number" ? `Edges: ${data.edgeCount}` : null,
tokenTitle,
...qitemHoverLines,
].filter((line): line is string => Boolean(line));
const hoverHint = hoverHintLines.join("\n");
@@ -232,13 +239,14 @@ export function RigNode({ data }: { data: RigNodeData }) {
</div>
{/* Body */}
<div className="p-3 space-y-2">
{/* Runtime info */}
{runtimeModel && (
<div className="font-mono text-[8px] text-stone-500">
RUNTIME: {runtimeModel}
</div>
)}
<div className="space-y-1 px-2 py-1.5">
<div className="truncate font-mono text-[8px] leading-tight text-stone-500">
{data.canonicalSessionName ?? data.logicalId}
</div>
<div className="truncate font-mono text-[8px] uppercase tracking-[0.12em] text-stone-400">
{runtimeModel || data.profile || "runtime unknown"}
</div>
{/* Spec hint */}
{data.resolvedSpecName && (
@@ -248,23 +256,32 @@ export function RigNode({ data }: { data: RigNodeData }) {
)}
{/* Context usage — prominent big number per founder directive */}
{data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number" ? (
<div className="flex items-end justify-between gap-3 pt-0.5">
{data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number" ? (
<div
className={`font-mono text-base font-bold leading-none ${
data.contextUsedPercentage >= 80 ? "text-red-600" :
data.contextUsedPercentage >= 60 ? "text-amber-600" :
"text-green-700"
}${!data.contextFresh ? " opacity-50" : ""}`}
data-testid="context-badge"
title={data.contextFresh ? "Context usage (fresh)" : "Context usage (stale sample)"}
>
{data.contextUsedPercentage}%
</div>
) : (
<div className="font-mono text-xs text-stone-400" data-testid="context-badge-unknown">
?
</div>
)}
<div
className={`font-mono text-base font-bold leading-tight ${
data.contextUsedPercentage >= 80 ? "text-red-600" :
data.contextUsedPercentage >= 60 ? "text-amber-600" :
"text-green-700"
}${!data.contextFresh ? " opacity-50" : ""}`}
data-testid="context-badge"
title={data.contextFresh ? "Context usage (fresh)" : "Context usage (stale sample)"}
className={`font-mono text-base font-bold leading-none tracking-[0.02em] ${tokenLabel ? "text-stone-500" : "text-stone-300"}`}
data-testid="token-total"
title={tokenTitle ?? "Token sample unavailable"}
>
{data.contextUsedPercentage}%
{tokenLabel ?? "--"}
</div>
) : (
<div className="font-mono text-xs text-stone-400" data-testid="context-badge-unknown">
?
</div>
)}
</div>
{/* Restore outcome */}
{data.restoreOutcome && data.restoreOutcome !== "n-a" && (
@@ -14,6 +14,7 @@ import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
import { ActivityRing } from "./ActivityRing.js";
import { getActivityCardClasses, getActivityCardSignal } from "./activity-card-visuals.js";
import type { TopologyActivityVisual } from "../../lib/topology-activity.js";
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../../lib/token-format.js";
interface HybridPodGroupNodeData {
podDisplayName?: string | null;
@@ -37,6 +38,8 @@ interface HybridAgentNodeData {
contextUsedPercentage?: number | null;
contextFresh?: boolean;
contextAvailability?: string | null;
contextTotalInputTokens?: number | null;
contextTotalOutputTokens?: number | null;
agentActivity?: AgentActivitySummary | null;
currentQitems?: unknown[];
rigId?: string | null;
@@ -93,6 +96,9 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
const activityCard = getActivityCardSignal({ activityRing: data.activityRing, activityState });
const runtimeModel = [data.runtime, data.model].filter(Boolean).join(" / ");
const contextKnown = data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number";
const tokenTotal = sumTokenCounts(data.contextTotalInputTokens, data.contextTotalOutputTokens);
const tokenLabel = formatCompactTokenCount(tokenTotal);
const tokenTitle = formatTokenTotalTitle(data.contextTotalInputTokens, data.contextTotalOutputTokens);
const card = (
<div
@@ -101,6 +107,7 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
data.canonicalSessionName,
`activity: ${activityLabel}${activityStale ? " (stale)" : ""}`,
runtimeModel || null,
tokenTitle,
].filter(Boolean).join("\n")}
data-activity-card-state={activityCard.state}
data-activity-card-flash={activityCard.flash ?? "none"}
@@ -151,20 +158,35 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
event.stopPropagation();
cmuxLaunch.mutate({ rigId: data.rigId!, logicalId: data.logicalId });
}}
className="absolute right-1.5 top-7 z-10 inline-flex h-6 w-6 items-center justify-center border border-outline-variant bg-white/90 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.14)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
className="absolute right-1.5 top-6 z-10 inline-flex h-6 w-6 items-center justify-center border border-outline-variant bg-white/90 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.14)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
>
<PanelsTopLeft className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : null}
<div className="space-y-1 px-2 py-1.5">
<div className="truncate font-mono text-[7px] text-stone-500">
<div className="truncate font-mono text-[8px] leading-tight text-stone-500">
{data.canonicalSessionName ?? data.logicalId}
</div>
<div className="truncate font-mono text-[7px] uppercase tracking-[0.08em] text-stone-400">
<div className="truncate font-mono text-[7px] uppercase tracking-[0.12em] text-stone-400">
{runtimeModel || data.resolvedSpecName || data.profile || "runtime unknown"}
</div>
<div className={cn("font-mono text-[12px] font-bold leading-none", contextClass(data.contextUsedPercentage, data.contextFresh))}>
{contextKnown ? `${data.contextUsedPercentage}%` : "--"}
<div className="flex items-end justify-between gap-2 pt-0.5">
<div
className={cn("font-mono text-[14px] font-bold leading-none", contextClass(data.contextUsedPercentage, data.contextFresh))}
data-testid="hybrid-context-badge"
>
{contextKnown ? `${data.contextUsedPercentage}%` : "--"}
</div>
<div
className={cn(
"font-mono text-[13px] font-bold leading-none tracking-[0.02em]",
tokenLabel ? "text-stone-500" : "text-stone-300",
)}
data-testid="hybrid-token-total"
title={tokenTitle ?? "Token sample unavailable"}
>
{tokenLabel ?? "--"}
</div>
</div>
</div>
<Handle type="source" position={Position.Right} className="opacity-0" />
+1 -1
View File
@@ -4,7 +4,7 @@ import { Position, type Edge, type Node } from "@xyflow/react";
const PREFIX_DELIMITER = "::";
export const HYBRID_AGENT_WIDTH = 166;
export const HYBRID_AGENT_HEIGHT = 116;
export const HYBRID_AGENT_HEIGHT = 94;
export const HYBRID_POD_HEADER_HEIGHT = 38;
export const HYBRID_POD_PADDING_X = 28;
export const HYBRID_POD_PADDING_BOTTOM = 22;
+43
View File
@@ -0,0 +1,43 @@
function cleanTokenCount(value: number | null | undefined): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
return Math.round(value);
}
function formatScaled(value: number): string {
const abs = Math.abs(value);
const rounded = abs < 10 ? Math.round(value * 10) / 10 : Math.round(value);
return String(rounded).replace(/\.0$/, "");
}
export function sumTokenCounts(
inputTokens: number | null | undefined,
outputTokens: number | null | undefined,
): number | null {
const input = cleanTokenCount(inputTokens);
const output = cleanTokenCount(outputTokens);
if (input === null && output === null) return null;
return (input ?? 0) + (output ?? 0);
}
export function formatCompactTokenCount(value: number | null | undefined): string | null {
const count = cleanTokenCount(value);
if (count === null) return null;
if (count >= 999_500) return `${formatScaled(count / 1_000_000)}m`;
if (count >= 1_000) return `${formatScaled(count / 1_000)}k`;
return count.toLocaleString("en-US");
}
export function formatTokenTotalTitle(
inputTokens: number | null | undefined,
outputTokens: number | null | undefined,
): string | null {
const input = cleanTokenCount(inputTokens);
const output = cleanTokenCount(outputTokens);
const total = sumTokenCounts(input, output);
if (total === null) return null;
const lines = [`Tokens: ${total.toLocaleString("en-US")}`];
if (input !== null) lines.push(`Input: ${input.toLocaleString("en-US")}`);
if (output !== null) lines.push(`Output: ${output.toLocaleString("en-US")}`);
return lines.join("\n");
}
@@ -1,9 +1,13 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactFlowProvider } from "@xyflow/react";
import type React from "react";
import { readFileSync } from "node:fs";
import path from "node:path";
import { ActivityRing } from "../src/components/topology/ActivityRing.js";
import { HotPotatoEdge } from "../src/components/topology/HotPotatoEdge.js";
import { HybridAgentNode } from "../src/components/topology/HybridTopologyNodes.js";
import { fallbackActivityCardState, getActivityCardClasses } from "../src/components/topology/activity-card-visuals.js";
import {
HOT_POTATO_WITHIN_RIG_DURATION_MS,
@@ -52,6 +56,15 @@ function edgeProps(reducedMotion: boolean) {
} as any;
}
function renderHybridNode(children: React.ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ReactFlowProvider>{children}</ReactFlowProvider>
</QueryClientProvider>,
);
}
describe("P5.3 ActivityRing and HotPotatoEdge", () => {
afterEach(() => cleanup());
@@ -106,6 +119,29 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(getActivityCardClasses({ state: "active", flash: "source", reducedMotion: true })).toContain("activity-card-reduced-motion");
});
it("hybrid agent card renders compact context and token totals", () => {
renderHybridNode(
<HybridAgentNode
data={{
logicalId: "driver",
role: "driver",
runtime: "claude-code",
model: null,
status: "running",
contextAvailability: "known",
contextUsedPercentage: 14,
contextFresh: true,
contextTotalInputTokens: 200_000,
contextTotalOutputTokens: 19_000,
canonicalSessionName: "velocity-driver@openrig-velocity",
}}
/>,
);
expect(screen.getByTestId("hybrid-context-badge").textContent).toBe("14%");
expect(screen.getByTestId("hybrid-token-total").textContent).toBe("219k");
});
it("source scan keeps activity and packet layers production-reachable", () => {
const srcRoot = path.resolve(__dirname, "../src");
const host = readFileSync(path.join(srcRoot, "components/topology/HostMultiRigGraph.tsx"), "utf8");
+7 -3
View File
@@ -523,8 +523,8 @@ describe("RigGraph", () => {
// React Flow uses our custom node type — nodes have class react-flow__node-rigNode
const customNodes = container.querySelectorAll(".react-flow__node-rigNode");
expect(customNodes.length).toBe(2);
// RigNode renders runtime text in combined format
expect(screen.getByText("RUNTIME: claude-code · opus")).toBeDefined();
// RigNode renders runtime text in the compact card grammar.
expect(screen.getByText("claude-code · opus")).toBeDefined();
});
});
});
@@ -547,7 +547,7 @@ describe("RigNode", () => {
);
expect(screen.getByText("impl")).toBeDefined();
expect(screen.getByText("RUNTIME: claude-code · opus")).toBeDefined();
expect(screen.getByText("claude-code · opus")).toBeDefined();
expect(screen.queryByText("WORKER")).toBeNull();
// PL-019: dot now reflects agentActivity (was startupStatus); no
// agentActivity attached → "unknown" state, desaturated stone.
@@ -1624,6 +1624,7 @@ describe("RigNode spec hint", () => {
logicalId: "dev.impl", role: "worker", runtime: "claude-code",
model: null, status: "running", binding: null,
contextAvailability: "known", contextUsedPercentage: 85, contextFresh: true,
contextTotalInputTokens: 120_000, contextTotalOutputTokens: 14_000,
}} />
</ReactFlowProvider>
);
@@ -1633,6 +1634,9 @@ describe("RigNode spec hint", () => {
expect(badge.className).toContain("text-red-600"); // >=80 = red
expect(badge.className).toContain("font-bold");
expect(badge.className).not.toContain("opacity-50");
const tokenTotal = screen.getByTestId("token-total");
expect(tokenTotal.textContent).toContain("134k");
expect(tokenTotal.getAttribute("title")).toContain("Tokens: 134,000");
});
it("renders stale context with reduced opacity", () => {
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../src/lib/token-format.js";
describe("token-format", () => {
it("formats compact token totals for graph cards", () => {
expect(formatCompactTokenCount(999)).toBe("999");
expect(formatCompactTokenCount(1_234)).toBe("1.2k");
expect(formatCompactTokenCount(123_000)).toBe("123k");
expect(formatCompactTokenCount(1_000_000)).toBe("1m");
expect(formatCompactTokenCount(123_000_000)).toBe("123m");
});
it("sums known input and output token counts", () => {
expect(sumTokenCounts(120_000, 14_000)).toBe(134_000);
expect(sumTokenCounts(null, 14_000)).toBe(14_000);
expect(sumTokenCounts(null, undefined)).toBeNull();
});
it("builds an exact tooltip for available token counts", () => {
expect(formatTokenTotalTitle(120_000, 14_000)).toBe([
"Tokens: 134,000",
"Input: 120,000",
"Output: 14,000",
].join("\n"));
});
});