refactor(webapp): move dashboard-agent render UI to the UI PR

This commit is contained in:
Katia Bulatova
2026-08-11 14:11:52 +00:00
parent db414b7286
commit 2aeb32fa18
23 changed files with 65 additions and 2726 deletions
@@ -1,31 +0,0 @@
import type {
ActionsBlock as ActionsBlockPayload,
AgentIntent,
} from "@internal/dashboard-agent-contracts";
import { Button } from "~/components/primitives/Buttons";
import { ChatActionsRow } from "./chat-layout";
import { renderableActions } from "./view-actions";
export function ActionsBlock({
block,
onIntent,
}: {
block: ActionsBlockPayload;
onIntent?: (intent: AgentIntent) => void;
}) {
const renderable = renderableActions(block.actions);
if (!onIntent || renderable.length === 0) return null;
return (
<ChatActionsRow>
{renderable.map((action, i) => (
<Button
key={i}
variant={i === 0 ? "primary/small" : "secondary/small"}
onClick={() => onIntent(action.intent as AgentIntent)}
>
{action.label}
</Button>
))}
</ChatActionsRow>
);
}
@@ -1,24 +1,17 @@
import type { UIMessage } from "@ai-sdk/react";
import { memo, useMemo, useRef } from "react";
import { memo } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { MessageBubble, renderPart } from "~/components/runs/v3/agent/AgentMessageView";
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
import { reuseWinners } from "./investigation-winners";
import { answerContinuesAfter } from "./view-actions";
import { ViewBlocks } from "./view-catalog";
// The shared MessageBubble renders `step-start` parts as a dashed "step" separator —
// useful in the run inspector / playground, just noise in this simple chat.
// Cached so a stripped message keeps its identity across renders and memoization holds.
const strippedMessages = new WeakMap<UIMessage, UIMessage>();
// The shared MessageBubble renders `step-start` parts as a dashed "step"
// separator — useful in the run inspector / playground, just noise in this
// simple chat. Drop them before rendering (reference preserved when there are
// none, so memoization still holds for those messages).
function stripStepParts(message: UIMessage): UIMessage {
if (!message.parts?.some((p) => p.type === "step-start")) return message;
const cached = strippedMessages.get(message);
if (cached) return cached;
const stripped = { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
strippedMessages.set(message, stripped);
return stripped;
return { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
}
// A completed render_view tool part carries a `{ blocks }` view spec the agent
@@ -30,109 +23,28 @@ function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } |
return Array.isArray(p.output?.blocks) ? { blocks: p.output!.blocks! } : null;
}
function hostViewBlocks(part: UIMessage["parts"][number]): unknown[] | null {
const p = part as { type: string; data?: { blocks?: unknown[] } };
if (p.type !== "data-view") return null;
return Array.isArray(p.data?.blocks) ? p.data!.blocks! : null;
}
// Both carriers render as cards, so whichever one wins a revision is one the panel
// can actually draw — a host-written card can never suppress a tool-rendered one
// into nothing.
function viewBlocksFor(part: UIMessage["parts"][number]): unknown[] | null {
return viewSpecFor(part)?.blocks ?? hostViewBlocks(part);
}
type InvestigationRef = { id: string; revision: number };
function investigationRef(block: unknown): InvestigationRef | null {
const b = block as { type?: string; id?: string; revision?: number };
if (b?.type !== "investigation" || typeof b.id !== "string") return null;
return { id: b.id, revision: typeof b.revision === "number" ? b.revision : 0 };
}
/**
* Per investigation id, the one `messageId:partIndex` allowed to render: highest revision.
* Indexed over the same stripped parts the renderer walks, so the two agree on what part 0 is.
*/
export function winningInvestigationOccurrences(messages: UIMessage[]): Map<string, string> {
const best = new Map<string, { revision: number; occurrence: string }>();
for (const message of messages.map(stripStepParts)) {
(message.parts ?? []).forEach((part, partIndex) => {
for (const block of viewBlocksFor(part) ?? []) {
const ref = investigationRef(block);
if (!ref) continue;
const current = best.get(ref.id);
if (!current || ref.revision >= current.revision) {
best.set(ref.id, { revision: ref.revision, occurrence: `${message.id}:${partIndex}` });
}
}
});
}
return new Map([...best.entries()].map(([id, w]) => [id, w.occurrence]));
}
// The stable identity is the point: a fresh `Map` re-renders the whole transcript per token.
function useInvestigationWinners(messages: UIMessage[]): Map<string, string> {
const previous = useRef<Map<string, string>>();
const next = useMemo(() => winningInvestigationOccurrences(messages), [messages]);
previous.current = reuseWinners(previous.current, next);
return previous.current;
}
function withoutSupersededInvestigations(
blocks: unknown[],
occurrence: string,
winners: Map<string, string> | undefined
): unknown[] {
if (!winners) return blocks;
return blocks.filter((block) => {
const ref = investigationRef(block);
return !ref || winners.get(ref.id) === occurrence;
});
}
// Renders one message. Assistant messages carrying a view spec get the catalog
// cards (plus the gather tool rows / lead-in text for transparency); everything
// else uses the shared MessageBubble unchanged, so its streaming memoization is
// preserved for the common case.
export function DashboardAgentMessageBubble({
// Renders one message. Assistant messages that include a completed render_view
// part get the catalog cards (plus the gather tool rows / lead-in text for
// transparency); everything else uses the shared MessageBubble unchanged, so
// its streaming memoization is preserved for the common case.
const DashboardAgentMessageBubble = memo(function DashboardAgentMessageBubble({
message,
investigationWinners,
}: {
message: UIMessage;
/** See {@link winningInvestigationOccurrences}. */
investigationWinners?: Map<string, string>;
}) {
if (message.role !== "assistant" || !message.parts?.some((p) => viewBlocksFor(p))) {
if (message.role !== "assistant" || !message.parts?.some((p) => viewSpecFor(p))) {
return <MessageBubble message={message} />;
}
return (
<div className="space-y-2">
{message.parts.map((part, i) => {
const spec = viewBlocksFor(part);
if (!spec) return renderPart(part, i);
const blocks = withoutSupersededInvestigations(
spec,
`${message.id}:${i}`,
investigationWinners
);
if (blocks.length === 0) return null;
// No `onIntent`: nothing here can act on one yet, so the cards drop their
// action rows rather than offer buttons that would do nothing.
return (
<ViewBlocks
key={i}
blocks={blocks as never}
answered={answerContinuesAfter(message.parts as never, i)}
/>
);
const spec = viewSpecFor(part);
if (spec) return <ViewBlocks key={i} blocks={spec.blocks as never} />;
return renderPart(part, i);
})}
</div>
);
}
const MemoizedMessageBubble = memo(DashboardAgentMessageBubble);
});
// Renders the conversation with the shared agent message renderer — the same
// MessageBubble the run inspector and playground use, so agent output looks
@@ -148,19 +60,12 @@ export function DashboardAgentMessages({
error?: Error;
}) {
const rootRef = useAutoScrollToBottom([messages, isThinking]);
// Must be the exact parts the bubbles render: the winners map keys by part index.
const stripped = useMemo(() => messages.map(stripStepParts), [messages]);
const investigationWinners = useInvestigationWinners(stripped);
return (
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div ref={rootRef} className="space-y-4 p-4">
{stripped.map((message) => (
<MemoizedMessageBubble
key={message.id}
message={message}
investigationWinners={investigationWinners}
/>
{messages.map((message) => (
<DashboardAgentMessageBubble key={message.id} message={stripStepParts(message)} />
))}
{isThinking && (
<div className="flex items-center gap-2 text-sm text-text-dimmed">
@@ -1,89 +0,0 @@
import type { InvestigationBlock } from "@internal/dashboard-agent-contracts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
import { InvestigationCard } from "./InvestigationCard";
/**
* What the card actually puts on the page, rather than what its source says. Static markup,
* so it proves the rendered output and nothing about interaction: a click is not exercised.
*/
const HYPOTHESIS = {
id: "h1",
statement: "The receipt builder is handed a null order id.",
verdict: "validated" as const,
evidence: [],
};
function block(overrides: {
hypotheses?: InvestigationBlock["investigation"]["hypotheses"];
actions?: NonNullable<InvestigationBlock["capabilities"]>["actions"];
}): InvestigationBlock {
return {
type: "investigation",
id: "inv_1",
revision: 0,
version: 1,
investigation: {
outcome: "concluded",
severity: "crit",
confidence: "high",
title: "send-order-receipt fails on every retry",
headline: "Every attempt dies on a null order id.",
remediation: "Guard the receipt builder against a missing order.",
hypotheses: overrides.hypotheses ?? [],
evidence: [],
},
...(overrides.actions
? { capabilities: { version: 1, actions: overrides.actions } }
: undefined),
} as InvestigationBlock;
}
// The Button primitive reads both of these for its shortcut hints.
function markup(props: Parameters<typeof InvestigationCard>[0]) {
return renderToStaticMarkup(
createElement(
OperatingSystemContextProvider,
{ platform: "mac" },
createElement(ShortcutsProvider, null, createElement(InvestigationCard, props))
)
);
}
describe("the card's sections appear only when they have something in them", () => {
it("leaves out an empty Hypotheses heading, the way Evidence already does", () => {
const html = markup({ block: block({}), defaultExpanded: true });
expect(html).not.toContain("Hypotheses");
expect(html).not.toContain("Evidence");
});
it("shows the heading once there is a hypothesis under it", () => {
const html = markup({ block: block({ hypotheses: [HYPOTHESIS] }), defaultExpanded: true });
expect(html).toContain("Hypotheses");
expect(html).toContain("The receipt builder is handed a null order id.");
});
});
describe("action buttons need a host to hand the intent to", () => {
const actions = [
{
kind: "ask_follow_up" as const,
label: "Keep digging",
intent: { kind: "ask" as const, prompt: "Keep digging into the receipt failures." },
},
];
it("renders no button when the host passes no onIntent, rather than a dead one", () => {
const html = markup({ block: block({ actions }), defaultExpanded: true });
expect(html).not.toContain("Keep digging");
});
it("renders the same action once a host can act on it", () => {
const html = markup({ block: block({ actions }), defaultExpanded: true, onIntent: () => {} });
expect(html).toContain("Keep digging");
});
});
@@ -1,41 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("./InvestigationCard.tsx", import.meta.url), "utf8");
describe("InvestigationCard purity", () => {
it("imports nothing from Remix", () => {
expect(source).not.toMatch(/from\s+"@remix-run\//);
});
it("imports no app hooks and no server module", () => {
expect(source).not.toMatch(/from\s+"~\/hooks\//);
expect(source).not.toMatch(/\.server"/);
});
it("calls no hook other than useState", () => {
const hooks = [...source.matchAll(/\buse([A-Z]\w*)\(/g)].map((match) => `use${match[1]}`);
expect([...new Set(hooks)]).toEqual(["useState"]);
});
it("resolves evidence URIs through the host, never a route of its own", () => {
expect(source).toMatch(/resolveUri/);
expect(source).not.toMatch(/\/orgs\//);
});
it("hands its actions to the host as intents, and never composes its own", () => {
expect(source).toMatch(/capabilities\?\.actions/);
expect(source).toMatch(/onIntent\(action\.intent\)/);
expect(source).not.toMatch(/kind:\s*"(ask|navigate)"/);
expect(source).toMatch(/ChatActionsRow/);
});
it("renders no spinner — the transcript owns the one live progress element", () => {
// A spinner in the card would restart on every revision.
expect(source).not.toMatch(/AgentSpinner|ChatProgress|ChatPendingTool/);
});
it("renders nothing action-shaped without a host to hand intents to", () => {
expect(source).toMatch(/if \(!onIntent \|\| actions\.length === 0\) return null;/);
});
});
@@ -1,254 +0,0 @@
// `id` is the investigationId and `revision` climbs: re-emitting replaces, never stacks.
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import type {
AgentIntent,
Evidence,
HypothesisVerdict,
InvestigationAction,
InvestigationBlock,
InvestigationHypothesis,
InvestigationSeverity,
} from "@internal/dashboard-agent-contracts";
import { useState } from "react";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import {
CategoryBadge,
ConfidenceBadge,
EVIDENCE_ROW_CLASS,
SeverityBadge,
VerdictBadge,
} from "./agent-badges";
import { AgentCard, AgentCardBody, AgentCardHeader } from "./agent-card";
import { ChatActionsRow } from "./chat-layout";
import type { ResolvedUri } from "./ReportView";
const SEVERITY_LABELS: Record<InvestigationSeverity, string> = {
info: "Info",
warn: "Degraded",
crit: "Critical",
};
const VERDICT_LABELS: Record<HypothesisVerdict, string> = {
testing: "Testing",
validated: "Validated",
invalidated: "Ruled out",
};
type ResolveUri = (uri: string) => ResolvedUri | null;
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<h4 className="text-xs font-medium uppercase tracking-wide text-text-dimmed">{title}</h4>
{children}
</div>
);
}
function EvidenceItem({
evidence,
stacked,
resolveUri,
}: {
evidence: Evidence;
stacked?: boolean;
resolveUri?: ResolveUri;
}) {
const resolved = resolveUri?.(evidence.uri) ?? null;
return (
<li className={stacked ? "space-y-1.5" : EVIDENCE_ROW_CLASS}>
{/* The Badge primitive is a grid, so `w-fit` is needed to stop it stretching. */}
<CategoryBadge className="w-fit justify-self-start">{evidence.kind}</CategoryBadge>
<div className="min-w-0 space-y-1.5">
<p className="text-xs text-text-bright">{evidence.label}</p>
{resolved ? (
<a
href={resolved.url}
className="block break-all font-mono text-[10px] text-text-link transition hover:underline"
>
{resolved.label}
</a>
) : (
<div className="break-all font-mono text-[10px] text-text-dimmed">{evidence.uri}</div>
)}
{evidence.excerpt ? (
<pre className="overflow-x-auto rounded-sm border border-grid-bright bg-background-bright px-2 py-1.5 font-mono text-[11px] leading-relaxed text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{evidence.excerpt}
</pre>
) : null}
</div>
</li>
);
}
function HypothesisRow({
hypothesis,
resolveUri,
}: {
hypothesis: InvestigationHypothesis;
resolveUri?: ResolveUri;
}) {
return (
<li className="space-y-3 border-l-2 border-grid-bright pl-4">
<div className="flex flex-wrap items-center gap-2">
<VerdictBadge verdict={hypothesis.verdict}>
{VERDICT_LABELS[hypothesis.verdict]}
</VerdictBadge>
</div>
<p className="text-sm text-text-bright">{hypothesis.statement}</p>
{hypothesis.finding ? <p className="text-xs text-text-dimmed">{hypothesis.finding}</p> : null}
{hypothesis.evidence.length > 0 ? (
<ul className="space-y-5 pt-1">
{hypothesis.evidence.map((evidence, i) => (
<EvidenceItem key={i} evidence={evidence} stacked resolveUri={resolveUri} />
))}
</ul>
) : null}
</li>
);
}
function InvestigationActions({
actions,
onIntent,
}: {
actions: InvestigationAction[];
onIntent?: (intent: AgentIntent) => void;
}) {
if (!onIntent || actions.length === 0) return null;
return (
<div className="border-t border-grid-bright pt-4">
<ChatActionsRow>
{actions.map((action, i) => (
<Button
key={action.kind}
variant={i === 0 ? "primary/small" : "secondary/small"}
onClick={() => onIntent(action.intent)}
>
{action.label}
</Button>
))}
</ChatActionsRow>
</div>
);
}
export function InvestigationCard({
block,
defaultExpanded = false,
resolveUri,
onIntent,
answered = false,
}: {
block: InvestigationBlock;
defaultExpanded?: boolean;
resolveUri?: ResolveUri;
onIntent?: (intent: AgentIntent) => void;
/** The turn kept answering after this card, so "keep digging" has nothing to ask for. */
answered?: boolean;
}) {
const [expanded, setExpanded] = useState(defaultExpanded);
const investigation = block.investigation;
const concluded = investigation.outcome === "concluded";
return (
<AgentCard>
<AgentCardHeader className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-text-dimmed">Investigation</span>
<SeverityBadge severity={investigation.severity}>
{SEVERITY_LABELS[investigation.severity]}
</SeverityBadge>
<ConfidenceBadge confidence={investigation.confidence} />
</div>
{investigation.runId ? (
<div className="truncate font-mono text-xs text-text-dimmed">{investigation.runId}</div>
) : null}
</AgentCardHeader>
<AgentCardBody density="roomy">
<p className="text-sm font-medium text-text-bright">{investigation.title}</p>
<Section title={concluded ? "What happened" : "What we know"}>
<p className="text-sm text-text-dimmed">{investigation.headline}</p>
</Section>
{/* The schema makes `remediation` and `checkNext` mutually exclusive. */}
{concluded && investigation.remediation ? (
<Section title="How to fix">
<p className="text-sm text-text-dimmed">{investigation.remediation}</p>
</Section>
) : null}
{investigation.checkNext && investigation.checkNext.length > 0 ? (
<Section title="What to check next">
<ol className="list-decimal space-y-2 pl-5">
{investigation.checkNext.map((item, i) => (
<li key={i} className="text-sm text-text-dimmed">
{item}
</li>
))}
</ol>
</Section>
) : null}
{investigation.caveat ? (
<Callout variant="warning">{investigation.caveat.message}</Callout>
) : null}
<div className="space-y-4 border-t border-grid-bright pt-4">
<Button
variant="minimal/small"
onClick={() => setExpanded((v) => !v)}
LeadingIcon={expanded ? ChevronDownIcon : ChevronRightIcon}
aria-expanded={expanded}
>
<span className="flex items-center gap-1.5 text-xs text-text-dimmed">
{expanded ? "Hide how I worked this out" : "How I worked this out"}
<span className="text-text-faint">
({investigation.hypotheses.length} hypothes
{investigation.hypotheses.length === 1 ? "is" : "es"})
</span>
</span>
</Button>
{expanded ? (
<div className="space-y-5 pt-1">
{investigation.hypotheses.length > 0 ? (
<Section title="Hypotheses">
<ul className="space-y-5">
{investigation.hypotheses.map((hypothesis) => (
<HypothesisRow
key={hypothesis.id}
hypothesis={hypothesis}
resolveUri={resolveUri}
/>
))}
</ul>
</Section>
) : null}
{investigation.evidence.length > 0 ? (
<Section title="Evidence">
<ul className="space-y-3">
{investigation.evidence.map((evidence, i) => (
<EvidenceItem key={i} evidence={evidence} resolveUri={resolveUri} />
))}
</ul>
</Section>
) : null}
</div>
) : null}
</div>
<InvestigationActions
actions={(block.capabilities?.actions ?? []).filter(
(action) => !answered || action.kind !== "ask_follow_up"
)}
onIntent={onIntent}
/>
</AgentCardBody>
</AgentCard>
);
}
@@ -1,19 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("./ReportView.tsx", import.meta.url), "utf8");
describe("ReportView purity", () => {
it("imports nothing from Remix", () => {
expect(source).not.toMatch(/from\s+"@remix-run\//);
});
it("imports no hooks and no server module", () => {
expect(source).not.toMatch(/from\s+"~\/hooks\//);
expect(source).not.toMatch(/\.server"/);
});
it("calls no React hook of its own", () => {
expect(source).not.toMatch(/\buse[A-Z]\w*\(/);
});
});
@@ -1,426 +0,0 @@
/**
* The report card: the panel's rendering of a `report` view block.
*
* Structure, labels and wording come from `report-layout.ts` (`buildReportLayout`),
* the same spec the markdown and ANSI renderers consume, so the card, the CLI and
* the agent's grounding show one report. This file only decides what each layout
* piece looks like as a component; where the text surfaces use a glyph, the card
* uses colour and an icon.
*
* Pure component: props in, no Remix hooks, no loader data, no router context, so
* it renders identically in any host. That means the host resolves `trigger://`
* URIs via `resolveUri`, and in-app footer actions emit an `AgentIntent` instead
* of navigating. Only entries whose target is an external URL are real links.
*/
import {
isTriggerUri,
type AgentIntent,
type ReportViewModelPayload,
type TriggerUri,
} from "@internal/dashboard-agent-contracts";
import { type ReactNode } from "react";
import { healthMessages } from "~/presenters/v3/reports/health/health-messages";
import {
buildReportLayout,
fmtValue,
REPORT_LABELS,
reportFooterStyle,
type LayoutFinding,
type LayoutMetricRow,
} from "~/presenters/v3/reports/report-layout";
import { type ReportMessages } from "~/presenters/v3/reports/report-messages";
import { AgentBadge } from "./agent-badges";
import { seriesEndMs as toSeriesEndMs } from "./report-spark";
import {
ReportBody,
ReportCard,
ReportFindingLine,
ReportFooterAction,
ReportFooterActionLink,
ReportFooterLine,
ReportFooterLink,
ReportFooterNote,
ReportHeaderLine,
ReportHeadline,
ReportMetricList,
ReportMetricRow,
ReportNoteBlock,
ReportProse,
ReportProvenance,
ReportSeverityIcon,
type ReportFooterItem,
} from "./report-sparkline";
export type ResolvedUri = { label: string; url: string };
// --- messages ---------------------------------------------------------------
/**
* Fallback for a report with no registered catalog (an old transcript, a report
* that hasn't shipped its messages): show the raw codes rather than crash or
* invent prose.
*/
const PASSTHROUGH_MESSAGES: ReportMessages = {
metricLabel: (id) => id,
findingReason: (_type, reason) => reason,
readMessage: (code) => code,
exclusionMessage: (code) => code,
observationMessage: (code) => code,
annotationMessage: (code) => code,
statementMessage: (findingType, severity) => `${findingType} ${severity}`,
actionMessage: (code) => code,
};
const CATALOGS: Record<string, ReportMessages> = { health: healthMessages };
function messagesFor(title: string): ReportMessages {
return CATALOGS[title] ?? PASSTHROUGH_MESSAGES;
}
// --- links ------------------------------------------------------------------
/**
* What a `vm.links` entry points at: an external doc URL or a `trigger://` URI.
* They differ because only the host can turn a URI into a route.
*/
type LinkTarget =
| { kind: "none" }
| { kind: "external"; url: string }
| { kind: "resource"; uri: TriggerUri; resolved: ResolvedUri | null };
function classifyLink(
url: string | undefined,
resolveUri: ((uri: string) => ResolvedUri | null) | undefined
): LinkTarget {
if (!url) return { kind: "none" };
if (isTriggerUri(url)) return { kind: "resource", uri: url, resolved: resolveUri?.(url) ?? null };
if (/^https?:\/\//i.test(url)) return { kind: "external", url };
return { kind: "none" };
}
/**
* One footer entry, rendered the way its code says (`reportFooterStyle`). An
* in-app action emits a `navigate` intent, or an `ask` when the report named no
* target, so the user can still get the "how" from the agent.
*/
function footerEntryNode({
code,
label,
target,
onIntent,
pagePath,
}: {
code: string;
label: string;
target: LinkTarget;
onIntent?: (intent: AgentIntent) => void;
/** A host-resolved dashboard path for this action (settings pages). */
pagePath?: string;
}): ReactNode {
const style = reportFooterStyle(code);
if (style === "note") return <ReportFooterNote>{label}</ReportFooterNote>;
// A settings-page action the host resolved wins over everything: the user can
// self-serve it there (e.g. raising the env concurrency limit).
if (style === "action" && pagePath) {
return <ReportFooterActionLink href={pagePath}>{label}</ReportFooterActionLink>;
}
// A docs entry is always the docs button, whatever shape its link arrived in:
// external URL, resolved resource, or nothing (then its canonical docs page).
if (style === "docs") {
const href =
target.kind === "external"
? target.url
: target.kind === "resource" && target.resolved
? target.resolved.url
: DOCS_URL_FALLBACK[code];
if (href) {
return (
<ReportFooterActionLink href={href} docs>
{label}
</ReportFooterActionLink>
);
}
return <ReportFooterNote>{label}</ReportFooterNote>;
}
if (style === "reference") {
const href =
target.kind === "external"
? target.url
: target.kind === "resource" && target.resolved
? target.resolved.url
: REFERENCE_URL_FALLBACK[code];
if (href) {
return (
<ReportFooterLink href={href} external={!href.startsWith("/")}>
{label}
</ReportFooterLink>
);
}
return <ReportFooterNote>{label}</ReportFooterNote>;
}
// An action whose target is a URL stays a button; the arrow says it leaves.
const actionHref =
target.kind === "external"
? target.url
: target.kind === "none"
? ACTION_URL_FALLBACK[code]
: undefined;
if (actionHref) {
return <ReportFooterActionLink href={actionHref}>{label}</ReportFooterActionLink>;
}
const intent: AgentIntent =
target.kind === "resource"
? { kind: "navigate", target: target.uri }
: { kind: "ask", prompt: `How do I ${lowerFirst(label)}?` };
if (!onIntent) return <ReportFooterNote>{label}</ReportFooterNote>;
return <ReportFooterAction onClick={() => onIntent(intent)}>{label}</ReportFooterAction>;
}
function lowerFirst(text: string): string {
return text.charAt(0).toLowerCase() + text.slice(1);
}
/**
* Canonical docs pages for footer codes whose report entry carries no URL.
* Without one the entry degrades to prose, which reads as a bug.
*/
const DOCS_URL_FALLBACK: Record<string, string> = {
concurrency_docs: "https://trigger.dev/docs/queue-concurrency",
retries_docs: "https://trigger.dev/docs/errors-retrying",
queues_docs: "https://trigger.dev/docs/queues",
};
/**
* Canonical destinations for action codes whose report entry carries no URL, so
* the button opens the page instead of asking the agent how to get there.
* Unknown codes keep the `ask` fallback.
*/
const ACTION_URL_FALLBACK: Record<string, string> = {
contact_us_raise_limit: "https://trigger.dev/contact",
};
/** Same idea for cited references: a place to look must stay a link. */
const REFERENCE_URL_FALLBACK: Record<string, string> = {
check_control_plane: "https://status.trigger.dev",
check_platform_status: "https://status.trigger.dev",
};
// --- pieces -----------------------------------------------------------------
function MetricRow({
row,
windowMinutes,
seriesEndMs,
}: {
row: LayoutMetricRow;
windowMinutes: number;
seriesEndMs: number | null;
}) {
// The hero row's annotation is spelled out rather than tucked in with the baseline.
const annotation = row.note?.kind === "annotation" ? row.note.text : undefined;
return (
<ReportMetricRow
label={row.label}
value={row.value}
severity={row.severity}
subRows={row.subRows.length > 0 ? row.subRows : undefined}
delta={row.delta}
note={row.hero && annotation ? undefined : row.note?.text}
heroNote={row.hero ? annotation : undefined}
series={row.series}
windowMinutes={windowMinutes}
anomalyMinutes={row.anomalyMinutes}
seriesEndMs={seriesEndMs}
formatPoint={(value) => fmtValue(value, row.unit)}
/>
);
}
/**
* A finding's evidence: its metric grid, then the `why:` block. The verdict itself
* is the headline or the finding line above.
*/
function FindingBody({
finding,
windowMinutes,
seriesEndMs,
}: {
finding: LayoutFinding;
windowMinutes: number;
seriesEndMs: number | null;
}) {
return (
<div className="space-y-2.5">
<ReportMetricList>
{finding.metrics.map((row) => (
<MetricRow
key={row.id}
row={row}
windowMinutes={windowMinutes}
seriesEndMs={seriesEndMs}
/>
))}
</ReportMetricList>
<ReportNoteBlock label={REPORT_LABELS.why}>
{finding.why.map((line, i) => (
<ReportProse
key={i}
text={line}
entities={finding.attributionKey ? [finding.attributionKey] : undefined}
/>
))}
</ReportNoteBlock>
</div>
);
}
// --- card -------------------------------------------------------------------
export function ReportView({
vm,
/** The `trigger://…/report/{key}` this snapshot came from, shown as its provenance. */
reportUri,
/** Emitted when the user clicks a footer action. The host decides what to do. */
onIntent,
/** Host-supplied `trigger://` resolver. Without one, resource links stay intents. */
resolveUri,
/**
* Host-supplied dashboard paths for footer actions that live on a settings page
* rather than behind a URI, keyed by footer code. Only the host knows the
* org/project/env slugs.
*/
pagePaths,
}: {
vm: ReportViewModelPayload;
reportUri?: string;
onIntent?: (intent: AgentIntent) => void;
resolveUri?: (uri: string) => ResolvedUri | null;
pagePaths?: Record<string, string>;
}) {
const layout = buildReportLayout(vm, messagesFor(vm.title));
const severity = layout.headline.severity;
const seriesEndMs = toSeriesEndMs(vm.generatedAt);
const linkByKey = (key: string | undefined) =>
key === undefined ? undefined : vm.links.find((link) => link.key === key)?.url;
// Links a footer action already speaks for aren't repeated as reading matter.
const footerLinkKeys = new Set(layout.footer.map((entry) => entry.link).filter(Boolean));
const footerItems: ReportFooterItem[] = layout.footer.map((entry) => ({
code: entry.code,
node: footerEntryNode({
code: entry.code,
label: entry.label,
target: classifyLink(linkByKey(entry.link), resolveUri),
onIntent,
pagePath: pagePaths?.[entry.code],
}),
}));
// Resources the report cites, resolved to dashboard links by the host. Cited,
// not offered, so a text link; our docs still get the docs button.
for (const link of vm.links) {
if (footerLinkKeys.has(link.key)) continue;
const target = classifyLink(link.url, resolveUri);
if (target.kind === "external") {
footerItems.push({
code: link.key,
node:
reportFooterStyle(link.key) === "docs" ? (
<ReportFooterActionLink href={target.url} docs>
{link.label}
</ReportFooterActionLink>
) : (
<ReportFooterLink href={target.url} external>
{link.label}
</ReportFooterLink>
),
});
} else if (target.kind === "resource" && target.resolved) {
footerItems.push({
code: link.key,
node: (
<ReportFooterLink href={target.resolved.url}>{target.resolved.label}</ReportFooterLink>
),
});
}
}
return (
<ReportCard>
<ReportHeaderLine name={layout.header.name} meta={layout.header.meta}>
{layout.trust ? <AgentBadge tone="warning">{layout.trust.badge}</AgentBadge> : null}
</ReportHeaderLine>
<ReportBody dimmed={layout.trust !== undefined}>
<ReportHeadline
severity={severity}
tone={layout.headline.tone}
phrase={layout.headline.phrase}
continuation={layout.headline.text}
/>
{layout.trust ? <p className="text-sm text-warning">{layout.trust.note}</p> : null}
{layout.hero && layout.hero.expanded ? (
<FindingBody
finding={layout.hero}
windowMinutes={vm.windowMinutes}
seriesEndMs={seriesEndMs}
/>
) : null}
{layout.findings.length > 0 || layout.statements.length > 0 ? (
<div className="space-y-2.5">
{layout.findings.map((finding, i) => (
<div key={`${finding.type}-${i}`} className="space-y-2">
<ReportFindingLine
severity={finding.severity}
tone={finding.tone}
type={finding.label}
bright={finding.severity !== "ok"}
text={finding.text}
/>
{finding.expanded ? (
<div className="pl-[1.375rem]">
<FindingBody
finding={finding}
windowMinutes={vm.windowMinutes}
seriesEndMs={seriesEndMs}
/>
</div>
) : null}
</div>
))}
{layout.statements.map((statement, i) => (
<p key={`s${i}`} className="flex items-center gap-1.5 text-sm">
<ReportSeverityIcon severity={statement.severity} tone={statement.tone} />
<span className="text-text-dimmed">{statement.text}</span>
</p>
))}
</div>
) : null}
<ReportNoteBlock label={REPORT_LABELS.read}>
{layout.reads.map((read, i) => (
<ReportProse key={i} text={read} />
))}
</ReportNoteBlock>
<ReportFooterLine items={footerItems} />
{reportUri ? <ReportProvenance uri={reportUri} /> : null}
</ReportBody>
</ReportCard>
);
}
@@ -1,173 +0,0 @@
import {
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
NoSymbolIcon,
QuestionMarkCircleIcon,
} from "@heroicons/react/20/solid";
import { Badge } from "~/components/primitives/Badge";
import { cn } from "~/utils/cn";
export type AgentTone = "neutral" | "success" | "warning" | "error";
// Semantic tokens, not raw palette classes: raw ones are dark-theme only.
// The `system:` overrides stop the Badge `small` variant tinting every chip blue.
const TONE_BADGE: Record<AgentTone, string> = {
neutral:
"border-border-bright text-text-dimmed system:border-transparent system:bg-charcoal-500/10 system:text-text-dimmed",
success:
"border-success/40 text-success system:border-transparent system:bg-success/10 system:text-success",
warning:
"border-warning/40 text-warning system:border-transparent system:bg-warning/10 system:text-warning",
error:
"border-error/40 text-error system:border-transparent system:bg-error/10 system:text-error",
};
export const TONE_ICON_COLOR: Record<AgentTone, string> = {
neutral: "text-text-dimmed",
success: "text-success",
warning: "text-warning",
error: "text-error",
};
type IconComponent = (props: { className?: string }) => JSX.Element;
export function AgentBadge({
tone = "neutral",
icon: Icon,
className,
children,
}: {
tone?: AgentTone;
icon?: IconComponent;
className?: string;
children: React.ReactNode;
}) {
return (
<Badge
variant="small"
className={cn(
// `contrast-chip`: the tinted chip gains a ring as interface contrast rises.
"contrast-chip px-1.5 [&>span]:flex [&>span]:items-center [&>span]:gap-1",
TONE_BADGE[tone],
className
)}
>
{Icon ? <Icon className="size-3 shrink-0" /> : null}
{children}
</Badge>
);
}
export function CategoryBadge({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<AgentBadge tone="neutral" className={className}>
{children}
</AgentBadge>
);
}
export type AgentConfidence = "high" | "medium" | "low";
const CONFIDENCE_TONE: Record<AgentConfidence, AgentTone> = {
high: "success",
medium: "warning",
low: "neutral",
};
const CONFIDENCE_ICON: Record<AgentConfidence, IconComponent> = {
high: CheckCircleIcon,
medium: ExclamationTriangleIcon,
low: QuestionMarkCircleIcon,
};
const CONFIDENCE_LABEL: Record<AgentConfidence, string> = {
high: "High confidence",
medium: "Medium confidence",
low: "Low confidence",
};
export function ConfidenceBadge({ confidence }: { confidence: AgentConfidence }) {
return (
<AgentBadge tone={CONFIDENCE_TONE[confidence]} icon={CONFIDENCE_ICON[confidence]}>
{CONFIDENCE_LABEL[confidence]}
</AgentBadge>
);
}
export type AgentSeverity = "info" | "warn" | "crit";
const SEVERITY_TONE: Record<AgentSeverity, AgentTone> = {
info: "neutral",
warn: "warning",
crit: "error",
};
const SEVERITY_ICON: Record<AgentSeverity, IconComponent> = {
info: InformationCircleIcon,
warn: ExclamationTriangleIcon,
crit: ExclamationCircleIcon,
};
export function SeverityBadge({
severity,
children,
}: {
severity: AgentSeverity;
children: React.ReactNode;
}) {
return (
<AgentBadge tone={SEVERITY_TONE[severity]} icon={SEVERITY_ICON[severity]}>
{children}
</AgentBadge>
);
}
export type AgentVerdict = "testing" | "validated" | "invalidated";
const VERDICT_TONE: Record<AgentVerdict, AgentTone> = {
testing: "neutral",
validated: "success",
invalidated: "neutral",
};
const VERDICT_ICON: Record<AgentVerdict, IconComponent | undefined> = {
testing: undefined,
validated: CheckCircleIcon,
invalidated: NoSymbolIcon,
};
export function VerdictBadge({
verdict,
children,
}: {
verdict: AgentVerdict;
children: React.ReactNode;
}) {
return (
<AgentBadge tone={VERDICT_TONE[verdict]} icon={VERDICT_ICON[verdict]}>
{children}
</AgentBadge>
);
}
export const EVIDENCE_ROW_CLASS = "grid grid-cols-[6.5rem_1fr] items-start gap-x-3";
export function AgentStatusIcon({
tone,
icon: Icon,
className,
}: {
tone: AgentTone;
icon: IconComponent;
className?: string;
}) {
return <Icon className={cn("size-4 shrink-0", TONE_ICON_COLOR[tone], className)} />;
}
@@ -1,44 +0,0 @@
// The transcript's card chrome. `ChatCardSlot` places a card; this owns the box,
// so stacked cards can't drift apart on border, surface or header inset.
import type { ReactNode } from "react";
import { cn } from "~/utils/cn";
const CARD_BOX = "overflow-hidden rounded-lg border border-border-bright bg-background-dimmed";
/** One header inset for every card, whatever its body density. */
const CARD_HEADER = "border-b border-grid-bright bg-background-bright px-3 py-2";
const CARD_BODY: Record<AgentCardDensity, string> = {
compact: "space-y-4 px-3 py-3.5",
roomy: "space-y-5 px-3 py-4",
};
/** How much air a card's body gives its sections. */
export type AgentCardDensity = "compact" | "roomy";
export function AgentCard({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn(CARD_BOX, className)}>{children}</div>;
}
/** The card's top strip. `className` carries its own layout, never its inset. */
export function AgentCardHeader({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return <div className={cn(CARD_HEADER, className)}>{children}</div>;
}
export function AgentCardBody({
density = "compact",
className,
children,
}: {
density?: AgentCardDensity;
className?: string;
children: ReactNode;
}) {
return <div className={cn(CARD_BODY[density], className)}>{children}</div>;
}
@@ -1,4 +0,0 @@
// Transcript spacing lives here, so a card writes no spacing classes of its own.
export function ChatActionsRow({ children }: { children: React.ReactNode }) {
return <div className="flex shrink-0 flex-wrap items-center gap-1">{children}</div>;
}
@@ -1,45 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { reuseWinners, sameOccurrences } from "./investigation-winners";
describe("reuseWinners", () => {
it("keeps the previous map when the winners are unchanged", () => {
const first = new Map([["inv_1", "msg_a:0"]]);
const second = new Map([["inv_1", "msg_a:0"]]);
expect(sameOccurrences(first, second)).toBe(true);
expect(reuseWinners(first, second)).toBe(first);
});
it("takes the next map when a winner moves", () => {
const first = new Map([["inv_1", "msg_a:0"]]);
const moved = new Map([["inv_1", "msg_b:2"]]);
expect(reuseWinners(first, moved)).toBe(moved);
});
it("takes the next map when an investigation appears", () => {
const first = new Map([["inv_1", "msg_a:0"]]);
const grown = new Map([
["inv_1", "msg_a:0"],
["inv_2", "msg_b:1"],
]);
expect(reuseWinners(first, grown)).toBe(grown);
});
it("takes the next map on the first render", () => {
const only = new Map([["inv_1", "msg_a:0"]]);
expect(reuseWinners(undefined, only)).toBe(only);
});
});
// Structural: there is no jsdom here, so the wiring is asserted against the source.
describe("DashboardAgentMessages wiring", () => {
const source = readFileSync(join(__dirname, "DashboardAgentMessages.tsx"), "utf8");
it("stabilises the winners map and the stripped messages it renders", () => {
expect(source).toContain("reuseWinners(previous.current, next)");
expect(source).toContain("useInvestigationWinners(stripped)");
expect(source).toContain("useMemo(() => messages.map(stripStepParts), [messages])");
expect(source).toContain("strippedMessages.set(message, stripped)");
});
});
@@ -1,14 +0,0 @@
export function sameOccurrences(a: Map<string, string>, b: Map<string, string>): boolean {
if (a.size !== b.size) return false;
for (const [id, occurrence] of a) {
if (b.get(id) !== occurrence) return false;
}
return true;
}
export function reuseWinners(
previous: Map<string, string> | undefined,
next: Map<string, string>
): Map<string, string> {
return previous && sameOccurrences(previous, next) ? previous : next;
}
@@ -1,116 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { barTimesMs, condense, hotBarCount, MAX_BARS, seriesEndMs } from "./report-spark";
describe("condense", () => {
it("leaves a series that already fits alone, by reference", () => {
const points = [1, 2, 3];
expect(condense(points, 18)).toBe(points);
});
it("averages adjacent points down to the bar count", () => {
expect(condense([0, 2, 4, 6], 2)).toEqual([1, 5]);
});
it("covers every point exactly once", () => {
const points = Array.from({ length: 97 }, (_, i) => i);
const bars = condense(points, MAX_BARS);
expect(bars).toHaveLength(MAX_BARS);
// Each bar is a mean of a non-empty slice, so no bar is NaN and the whole
// series is inside the bars' range.
expect(bars.every((bar) => Number.isFinite(bar))).toBe(true);
expect(Math.min(...bars)).toBeGreaterThanOrEqual(0);
expect(Math.max(...bars)).toBeLessThanOrEqual(96);
});
it("never asks for a slice ending before the first point", () => {
// Why the removed `Math.max(end, 1)` was unreachable: past the early return
// `perBar > 1`, so even the first bar's end index is already at least 1.
for (let maxBars = 1; maxBars <= 30; maxBars++) {
for (let length = maxBars + 1; length <= maxBars + 40; length++) {
const perBar = length / maxBars;
for (let i = 0; i < maxBars; i++) {
expect(Math.floor((i + 1) * perBar)).toBeGreaterThanOrEqual(1);
}
}
}
});
it("gives the first bar the first points, not a slice starting past them", () => {
// The old `Math.max(end, 1)` claimed to guard an empty first slice. With
// `points.length > maxBars` the end index is already at least 1, so the guard
// never fired — and if it ever had, the first bar would be a single point.
expect(condense([10, 20, 30, 40, 50, 60], 3)).toEqual([15, 35, 55]);
});
});
describe("seriesEndMs", () => {
it("reads the presenter's timestamp", () => {
expect(seriesEndMs("2026-07-27T10:15:00.000Z")).toBe(Date.parse("2026-07-27T10:15:00.000Z"));
});
it("is null for a missing or unparseable timestamp", () => {
expect(seriesEndMs(undefined)).toBeNull();
expect(seriesEndMs("")).toBeNull();
expect(seriesEndMs("not a date")).toBeNull();
});
});
/**
* The bars are the report's, not the reader's: two people opening the same report an hour apart,
* and one reader re-rendering, must all see the same time on the same bar.
*/
describe("barTimesMs", () => {
const end = Date.parse("2026-07-27T10:00:00.000Z");
it("spreads the bars back from the series' end", () => {
const times = barTimesMs(4, 60, end);
expect(times).toEqual([
Date.parse("2026-07-27T09:00:00.000Z"),
Date.parse("2026-07-27T09:15:00.000Z"),
Date.parse("2026-07-27T09:30:00.000Z"),
Date.parse("2026-07-27T09:45:00.000Z"),
]);
});
it("returns the same times however long after the report they are asked for", () => {
expect(barTimesMs(4, 60, end)).toEqual(barTimesMs(4, 60, end));
});
it("gives every bar no time at all when the end is unknown", () => {
expect(barTimesMs(3, 60, null)).toEqual([null, null, null]);
});
it("has no bars to time when there are no bars", () => {
expect(barTimesMs(0, 60, end)).toEqual([]);
});
});
describe("hotBarCount", () => {
it("is none without an anomaly window", () => {
expect(hotBarCount(18, 60, undefined)).toBe(0);
});
it("marks the trailing bars the window covers", () => {
expect(hotBarCount(12, 60, 15)).toBe(3);
});
it("marks at least one bar, and never more than there are", () => {
expect(hotBarCount(12, 60, 1)).toBe(1);
expect(hotBarCount(12, 60, 600)).toBe(12);
});
});
/**
* Structural guard, not behavioural proof: it asserts the renderer's source never reaches for a
* clock, which is what keeps the bars above stable. It does not render the card.
*/
describe("the report card reads no clock", () => {
const sources = ["report-sparkline.tsx", "ReportView.tsx"];
it.each(sources)("%s calls neither Date.now nor new Date()", (file) => {
const source = readFileSync(new URL(`./${file}`, import.meta.url), "utf8");
expect(source).not.toMatch(/Date\.now\(\)/);
expect(source).not.toMatch(/new Date\(\s*\)/);
});
});
@@ -1,52 +0,0 @@
/**
* The sparkline's arithmetic. It lives here rather than in `report-sparkline.tsx` so it stays
* clock-free: bar timestamps come from the presenter's `generatedAt`, never from the renderer.
*/
/** How many bars a series is condensed to, so each bar stays wide enough to hover. */
export const MAX_BARS = 18;
/** Average adjacent points down so each bar is wide enough to read and hover. */
export function condense(points: number[], maxBars: number = MAX_BARS): number[] {
if (points.length <= maxBars) return points;
const perBar = points.length / maxBars;
return Array.from({ length: maxBars }, (_, i) => {
const slice = points.slice(Math.floor(i * perBar), Math.floor((i + 1) * perBar));
return slice.reduce((sum, v) => sum + v, 0) / Math.max(slice.length, 1);
});
}
/** The series' end, from the view model. Null when the report carries no usable timestamp. */
export function seriesEndMs(generatedAt: string | undefined): number | null {
if (!generatedAt) return null;
const ms = Date.parse(generatedAt);
return Number.isNaN(ms) ? null : ms;
}
/**
* Each bar's start, spread back from the series' end. All null when the end is unknown: a bar
* with no time reads as one, where a guessed time reads as a fact.
*/
export function barTimesMs(
barCount: number,
windowMinutes: number,
endMs: number | null
): (number | null)[] {
const length = Math.max(barCount, 0);
if (endMs === null || length === 0) return Array.from({ length }, () => null);
const windowMs = windowMinutes * 60_000;
const intervalMs = windowMs / length;
const startMs = endMs - windowMs;
return Array.from({ length }, (_, i) => startMs + i * intervalMs);
}
/** Trailing bars inside the anomaly window, which paint at full strength. */
export function hotBarCount(
barCount: number,
windowMinutes: number,
anomalyMinutes: number | undefined
): number {
const minutesPerBar = barCount > 0 ? windowMinutes / barCount : 0;
if (!anomalyMinutes || minutesPerBar <= 0) return 0;
return Math.min(barCount, Math.max(1, Math.round(anomalyMinutes / minutesPerBar)));
}
@@ -1,698 +0,0 @@
/**
* The parts a report card is built from: severity vocabulary, card chrome, the
* metric row and its sparkline. Both report cards render this layout, so the
* pieces take resolved strings rather than metric objects.
*
* Keep this file pure: no Remix hooks, no loader data, no router context. Footer
* `LinkButton`s are only ever given external URLs, which render as plain anchors.
*/
import {
ArrowUpRightIcon,
BookOpenIcon,
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
QuestionMarkCircleIcon,
} from "@heroicons/react/20/solid";
import { Children, Fragment, type ReactNode } from "react";
import { Bar, Cell, type TooltipProps } from "recharts";
import {
REPORT_LABELS,
reportFooterStyle,
type ReportFooterStyle,
type ReportTone,
} from "~/presenters/v3/reports/report-layout";
import { ActivityBarChart } from "~/components/metrics/ActivityBarChart";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { formatDateTime } from "~/components/primitives/DateTime";
import { Header3 } from "~/components/primitives/Headers";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import { cn } from "~/utils/cn";
import { AgentStatusIcon, type AgentTone } from "./agent-badges";
import { AgentCard, AgentCardBody, AgentCardHeader } from "./agent-card";
import { barTimesMs, condense, hotBarCount } from "./report-spark";
/** Both cards' severity type (`Severity` / `ReportSeverity`) resolves to this. */
export type ReportSeverityKey = "ok" | "warn" | "crit";
// Semantic tokens, not raw palette classes: only these are remapped by the theme
// layer (see tailwind.css). Keyed by tone, so a genuinely-unknown state can't
// borrow a verdict's colour.
export const SEVERITY_TEXT: Record<ReportTone, string> = {
ok: "text-success",
warn: "text-warning",
crit: "text-error",
neutral: "text-text-dimmed",
};
/** The same colours as CSS values, for the sparkline's line. */
const SEVERITY_COLOR: Record<ReportTone, string> = {
ok: "var(--color-success)",
warn: "var(--color-warning)",
crit: "var(--color-error)",
neutral: "var(--color-text-dimmed)",
};
const SEVERITY_TONE: Record<ReportTone, AgentTone> = {
ok: "success",
warn: "warning",
crit: "error",
neutral: "neutral",
};
const SEVERITY_ICON = {
ok: CheckCircleIcon,
warn: ExclamationTriangleIcon,
crit: ExclamationCircleIcon,
neutral: QuestionMarkCircleIcon,
} as const;
/**
* The state marker on a finding or a summary statement. `tone` is the shared
* layout's tone, which is the severity unless the state is genuinely unknown —
* the card's counterpart to the text surfaces' `○` glyph.
*/
export function ReportSeverityIcon({
severity,
tone,
className,
}: {
severity: ReportSeverityKey;
tone?: ReportTone;
className?: string;
}) {
const key = tone ?? severity;
return (
<AgentStatusIcon
tone={SEVERITY_TONE[key]}
icon={SEVERITY_ICON[key]}
className={cn("size-3.5", className)}
/>
);
}
// --- card chrome ------------------------------------------------------------
export function ReportCard({ children }: { children: ReactNode }) {
return <AgentCard>{children}</AgentCard>;
}
/**
* The quiet top line: the report's name, then its scope, period and baseline.
* Anything urgent belongs in the headline below.
*/
export function ReportHeaderLine({
name,
meta,
children,
}: {
name: string;
meta: string;
/** State badges that sit next to the name. */
children?: ReactNode;
}) {
return (
<AgentCardHeader className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-text-bright">{name}</span>
{children}
<span className="ml-auto text-xs text-text-dimmed">{meta}</span>
</AgentCardHeader>
);
}
/** The card body. */
export function ReportBody({ children, dimmed }: { children: ReactNode; dimmed?: boolean }) {
return <AgentCardBody className={cn(dimmed && "opacity-80")}>{children}</AgentCardBody>;
}
/** The verdict as one sentence: icon, the coloured phrase, then why. */
export function ReportHeadline({
severity,
tone,
phrase,
continuation,
}: {
severity: ReportSeverityKey;
tone?: ReportTone;
phrase: string;
continuation?: string;
}) {
return (
<p className="flex items-start gap-2 text-sm">
<ReportSeverityIcon
severity={severity}
tone={tone}
className={cn("shrink-0", (tone ?? severity) === "warn" ? "mt-1" : "mt-0.5")}
/>
<span>
<span className={cn("font-medium", SEVERITY_TEXT[tone ?? severity])}>{phrase}</span>
{continuation ? <span className="text-text-bright"> {continuation}</span> : null}
</span>
</p>
);
}
/**
* A finding other than the one in the headline. Fixed columns so consecutive
* lines start on the same vertical.
*/
export function ReportFindingLine({
severity,
tone,
type,
text,
bright,
}: {
severity: ReportSeverityKey;
tone?: ReportTone;
type: string;
text: string;
bright?: boolean;
}) {
return (
<p className="grid grid-cols-[1rem_4.5rem_minmax(0,1fr)] items-start gap-x-2">
<ReportSeverityIcon severity={severity} tone={tone} className="mt-0.5" />
<span className="mt-px text-xs uppercase tracking-wide text-text-dimmed">{type}</span>
<span className={cn("-mt-0.5 text-sm", bright ? "text-text-bright" : "text-text-dimmed")}>
{text}
</span>
</p>
);
}
// --- prose highlighting -----------------------------------------------------
/**
* Highlight rules for report prose. Quantities render bright and tabular,
* entities mono, verdict phrases bright and medium, everything else dimmed.
* Colour stays reserved for severity, so emphasis here is weight only.
*/
const QUANTITY_RE = /~?\d[\d,.]*\s?(?:%|×|\/min|ms\b|s\b|min\b|h\b)?/g;
const VERDICT_PHRASES = [
"not your code",
"not a code problem",
"not the workers",
"not the platform",
];
type ProseSegment = { text: string; kind: "plain" | "quantity" | "entity" | "verdict" };
function splitBy(
segments: ProseSegment[],
match: (text: string) => { start: number; end: number } | null,
kind: ProseSegment["kind"]
): ProseSegment[] {
return segments.flatMap((segment) => {
if (segment.kind !== "plain") return [segment];
const out: ProseSegment[] = [];
let rest = segment.text;
for (;;) {
const hit = match(rest);
if (!hit) break;
if (hit.start > 0) out.push({ text: rest.slice(0, hit.start), kind: "plain" });
out.push({ text: rest.slice(hit.start, hit.end), kind });
rest = rest.slice(hit.end);
}
if (rest) out.push({ text: rest, kind: "plain" });
return out;
});
}
/** Apply the highlight rules to one resolved prose line. */
export function ReportProse({ text, entities }: { text: string; entities?: string[] }) {
let segments: ProseSegment[] = [{ text, kind: "plain" }];
for (const entity of entities ?? []) {
if (!entity) continue;
segments = splitBy(
segments,
(t) => {
const i = t.indexOf(entity);
return i === -1 ? null : { start: i, end: i + entity.length };
},
"entity"
);
}
for (const phrase of VERDICT_PHRASES) {
segments = splitBy(
segments,
(t) => {
const i = t.toLowerCase().indexOf(phrase);
return i === -1 ? null : { start: i, end: i + phrase.length };
},
"verdict"
);
}
segments = splitBy(
segments,
(t) => {
QUANTITY_RE.lastIndex = 0;
const m = QUANTITY_RE.exec(t);
return m && m[0].trim().length > 0 ? { start: m.index, end: m.index + m[0].length } : null;
},
"quantity"
);
return (
<>
{segments.map((segment, i) => {
switch (segment.kind) {
case "quantity":
return (
<span key={i} className="font-medium tabular-nums text-text-bright">
{segment.text}
</span>
);
case "entity":
return (
<span key={i} className="font-mono text-xs text-text-bright">
{segment.text}
</span>
);
case "verdict":
return (
<span key={i} className="font-medium text-text-bright">
{segment.text}
</span>
);
default:
return <Fragment key={i}>{segment.text}</Fragment>;
}
})}
</>
);
}
/**
* A labelled block of lines. The label sits in its own column so the lines hang
* together as one indented paragraph.
*/
export function ReportNoteBlock({ label, children }: { label: string; children: ReactNode }) {
const lines = Children.toArray(children).filter(Boolean);
if (lines.length === 0) return null;
return (
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)] gap-x-1 text-sm">
<span className="text-text-dimmed">{label}</span>
<div className="space-y-0.5 text-text-dimmed">
{lines.map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</div>
);
}
// --- footer -----------------------------------------------------------------
// The footer vocabulary lives in the shared layout spec, so the card and the text
// surfaces classify a code the same way. `action` is a primary button, `docs` the
// docs button, `reference` a text link because a button would promise an action,
// and `note` is prose for an option stated rather than offered.
export { reportFooterStyle, type ReportFooterStyle };
/** A dimmed line that accompanies a row entry. */
const FOOTER_NOTE_LINES: Record<string, string> = {
check_control_plane: "There's nothing to fix on your side.",
};
/** One resolved footer entry: the code it came from, and what it renders as. */
export type ReportFooterItem = { code: string; node: ReactNode };
function isRowEntry(item: ReportFooterItem): boolean {
const style = reportFooterStyle(item.code);
return style === "action" || style === "docs" || style === "reference";
}
/**
* The footer: a "Next steps" heading, the controls in one wrapping row, and
* stated options as a dimmed line under it.
*/
export function ReportFooterLine({ items }: { items: ReportFooterItem[] }) {
const entries = items.filter((item) => item.node);
if (entries.length === 0) return null;
const row = entries.filter(isRowEntry);
const noteLines = entries
.map((item) => FOOTER_NOTE_LINES[item.code])
.filter((line): line is string => Boolean(line));
const rest = entries.filter((item) => !isRowEntry(item));
return (
<div className="space-y-2 border-t border-grid-bright pt-3">
<h4 className="text-xs font-medium uppercase tracking-wide text-text-dimmed">
{REPORT_LABELS.nextSteps}
</h4>
{row.length > 0 ? (
// text-xs so a text link in the row (a cited reference) sits at the
// same size as the buttons beside it.
<div className="flex flex-wrap items-center gap-2 text-xs">
{row.map((item, i) => (
<Fragment key={i}>{item.node}</Fragment>
))}
</div>
) : null}
{rest.length > 0 || noteLines.length > 0 ? (
<p className="text-sm leading-6 text-text-dimmed">
{noteLines.join(" ")}
{noteLines.length > 0 && rest.length > 0 ? " " : null}
{rest.map((item, i) => (
<Fragment key={i}>
{i > 0 ? " " : null}
{item.node}
</Fragment>
))}
</p>
) : null}
</div>
);
}
/**
* A footer entry that only cites a place to look. Underlined text, never a
* button: a button promises something happens here.
*/
export function ReportFooterLink({
href,
external,
children,
}: {
href: string;
external?: boolean;
children: ReactNode;
}) {
return (
<a
href={href}
{...(external ? { target: "_blank", rel: "noreferrer" } : {})}
// The app's link token and the app-wide external-link marker.
className="text-text-link underline decoration-text-link/40 underline-offset-2 transition hover:decoration-text-link"
>
{children}
{external ? (
<ArrowUpRightIcon className="ml-0.5 inline-block size-3.5 align-[-0.15em] text-text-dimmed" />
) : null}
</a>
);
}
/** A footer entry that states an option instead of offering one. */
export function ReportFooterNote({ children }: { children: ReactNode }) {
return <span className="text-text-dimmed">{children}</span>;
}
/**
* Keeps an `h-6` control on the text baseline inside the footer sentence without
* stretching the line it sits on.
*/
const INLINE_CONTROL = "inline-flex align-middle";
/** An in-app footer action. */
export function ReportFooterAction({
onClick,
children,
}: {
onClick: () => void;
children: ReactNode;
}) {
return (
<span className={INLINE_CONTROL}>
<Button variant="primary/small" onClick={onClick}>
{children}
</Button>
</span>
);
}
/** A footer action that lives at a URL: the same button, as a link. */
export function ReportFooterActionLink({
href,
docs,
children,
}: {
href: string;
docs?: boolean;
children: ReactNode;
}) {
const external = /^https?:\/\//i.test(href);
return (
<span className={INLINE_CONTROL}>
<LinkButton
to={href}
variant={docs ? "docs/small" : "primary/small"}
LeadingIcon={docs ? BookOpenIcon : undefined}
TrailingIcon={!docs && external ? ArrowUpRightIcon : undefined}
>
{children}
</LinkButton>
</span>
);
}
/** Where the snapshot came from, in the report's own URI vocabulary. */
export function ReportProvenance({ uri }: { uri: string }) {
return <div className="break-all font-mono text-[10px] text-text-faint">{uri}</div>;
}
// --- sparkline --------------------------------------------------------------
/** The fixed sparkline column. Keeps every sparkline aligned. */
const SPARK_WIDTH_CLASS = "w-[5.5rem]";
/** The chart's own width; the trailing peak label uses the column's remainder. */
const SPARK_WIDTH = 56;
type ReportSparkDatum = { count: number; date: Date | null; hot: boolean };
function ReportSparkTooltip({
active,
payload,
formatPoint,
}: TooltipProps<number, string> & { formatPoint: (value: number) => string }) {
if (!active || !payload || payload.length === 0) return null;
const entry = payload[0].payload as ReportSparkDatum;
return (
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
{entry.date ? (
<Header3 className="border-b border-b-border-bright pb-2">
{formatDateTime(entry.date, "UTC", [], false, true)}
</Header3>
) : null}
<div className="mt-2 text-xs tabular-nums text-text-bright">{formatPoint(entry.count)}</div>
{entry.hot ? <div className="mt-1 text-xs text-warning">in the anomaly window</div> : null}
</div>
</TooltipPortal>
);
}
/**
* A metric's series as an `ActivityBarChart`. Bars inside the anomaly window paint
* at full strength and the rest recede to a tint of the same colour, so the breach
* reads as one chart changing intensity rather than a second series.
*/
export function ReportSparkline({
points,
severity,
/** Minutes the whole series covers. Turns a bar into its tooltip time. */
windowMinutes,
/**
* Length of the anomaly window when it runs to the end of the series. The
* matching trailing bars paint at full strength.
*/
anomalyMinutes,
/** When the series ends, from the report's `generatedAt`. Turns a bar into a time. */
seriesEndMs,
/** The metric's own formatter, used by the tooltip and the peak label. */
formatPoint,
label,
className,
}: {
points: number[];
severity: ReportSeverityKey;
windowMinutes: number;
anomalyMinutes?: number;
seriesEndMs: number | null;
formatPoint: (value: number) => string;
label: string;
className?: string;
}) {
const bars = condense(points);
// The view model carries buckets, not timestamps, so spread them back from the
// series' end. Never from the renderer's clock: see `report-spark.ts`.
const times = barTimesMs(bars.length, windowMinutes, seriesEndMs);
const hotBars = hotBarCount(bars.length, windowMinutes, anomalyMinutes);
const data: ReportSparkDatum[] = bars.map((count, i) => ({
count,
date: times[i] === null ? null : new Date(times[i]!),
hot: i >= bars.length - hotBars,
}));
const color = SEVERITY_COLOR[severity];
const calm = `color-mix(in srgb, ${color} 35%, transparent)`;
const peak = points.length > 0 ? Math.max(...points) : 0;
return (
<div
className={cn(SPARK_WIDTH_CLASS, className)}
role="img"
aria-label={`${label} over the last ${windowMinutes} minutes, peak ${formatPoint(peak)}`}
>
<ActivityBarChart
data={data}
max={Math.max(...bars, 1)}
width={SPARK_WIDTH}
peak={formatPoint(peak)}
tooltip={<ReportSparkTooltip formatPoint={formatPoint} />}
>
<Bar dataKey="count" isAnimationActive={false} minPointSize={1}>
{data.map((entry, i) => (
<Cell key={i} fill={entry.hot || hotBars === 0 ? color : calm} />
))}
</Bar>
</ActivityBarChart>
</div>
);
}
// --- metric row -------------------------------------------------------------
/**
* Label, value, delta and sparkline in fixed columns, so every row's label and
* chart start on the same vertical whatever the value's width.
*/
/**
* Below a 19rem container the fixed tracks no longer fit beside the value, so the
* sparkline drops to its own line. The columns never change, so the value, delta
* and note stay on the same verticals at every panel width.
*/
const METRIC_ROW_CLASS =
"grid grid-cols-[6rem_minmax(0,1fr)_2.75rem_5.5rem] items-center gap-x-2 @max-[19rem]:grid-cols-[6rem_minmax(0,1fr)_2.75rem] @max-[19rem]:gap-y-1.5";
/** The sparkline cell: its own full-width line once the row goes narrow. */
const SPARK_CELL_CLASS = "@max-[19rem]:col-span-3 @max-[19rem]:justify-self-end";
// Labels are never truncated: the column fits the common ones and anything
// longer wraps.
const LABEL_CLASS = "text-xs uppercase leading-tight tracking-wide text-text-dimmed";
/** A metric's movement against its baseline. Direction is always an arrow. */
export type ReportDelta = { text: string; dir: "up" | "down" | "flat" };
/**
* A view model `Delta` as the row's arrow. A multiplier only reads as movement
* once it rounds past 1×; below that a metric with a baseline is flat, and one
* without a baseline has nothing to compare against.
*/
export function reportDelta(
delta: { dir: "up" | "down" | "flat"; mult?: number } | undefined,
hasBaseline: boolean
): ReportDelta | undefined {
if (delta && delta.mult !== undefined && delta.mult > 1 && delta.dir !== "flat") {
return { text: `${delta.dir === "up" ? "↑" : "↓"} ${delta.mult}×`, dir: delta.dir };
}
return hasBaseline ? { text: "→ flat", dir: "flat" } : undefined;
}
export function ReportMetricRow({
label,
value,
severity,
/** A composite metric's parts, indented under it as their own rows. */
subRows,
/** The movement against the baseline. */
delta,
/**
* The row's aside, such as its baseline. It goes in a tooltip because as
* trailing text it broke the sparkline column and read like part of the value.
*/
note,
/** The finding-explaining row's annotation. Joins `note` in the info tooltip. */
heroNote,
series,
windowMinutes,
anomalyMinutes,
seriesEndMs,
formatPoint,
}: {
label: string;
value: string;
severity: ReportSeverityKey;
subRows?: { label: string; value: string }[];
delta?: ReportDelta;
note?: string;
heroNote?: string;
series?: number[];
windowMinutes: number;
anomalyMinutes?: number;
seriesEndMs: number | null;
formatPoint: (value: number) => string;
}) {
const deltaClass =
delta?.dir === "up"
? severity === "ok"
? "text-text-dimmed"
: SEVERITY_TEXT[severity]
: delta?.dir === "down"
? "text-text-dimmed"
: "text-text-faint";
return (
<>
<li className={METRIC_ROW_CLASS}>
<span className={LABEL_CLASS}>{label}</span>
<span className="flex min-w-0 items-center gap-1.5">
<span className={cn("text-sm font-medium tabular-nums", SEVERITY_TEXT[severity])}>
{value}
</span>
{note || heroNote ? (
<InfoIconTooltip content={[heroNote, note].filter(Boolean).join(" · ")} />
) : null}
</span>
<span className={cn("whitespace-nowrap text-xs tabular-nums", deltaClass)}>
{delta?.text ?? ""}
</span>
{series && series.length > 0 ? (
<ReportSparkline
points={series}
severity={severity}
windowMinutes={windowMinutes}
anomalyMinutes={anomalyMinutes}
seriesEndMs={seriesEndMs}
formatPoint={formatPoint}
label={label}
className={SPARK_CELL_CLASS}
/>
) : (
// Keeps the column occupied so a series-less metric doesn't pull the
// rows out of alignment.
<span aria-hidden className="@max-[19rem]:hidden" />
)}
</li>
{(subRows ?? []).map((sub) => (
// A sub-row keeps the grid's columns so its number stays on the same
// vertical as every other row's value.
<li key={sub.label} className={METRIC_ROW_CLASS}>
{/* Indented under the parent label, shallow enough to stay inside the
6rem label column. */}
<span className={cn(LABEL_CLASS, "pl-6")}>{sub.label}</span>
<span className="whitespace-nowrap text-sm tabular-nums text-text-dimmed">
{sub.value}
</span>
</li>
))}
</>
);
}
/** The metric grid. Rows are `ReportMetricRow`s, which may expand to several. */
export function ReportMetricList({ children }: { children: ReactNode }) {
// The container the rows measure themselves against.
return <ul className="@container space-y-2.5">{children}</ul>;
}
@@ -1,67 +0,0 @@
import type { ActionsBlockAction } from "@internal/dashboard-agent-contracts";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { answerContinuesAfter, renderableActions } from "./view-actions";
const askAction: ActionsBlockAction = {
label: "Investigate it",
intent: { kind: "ask", prompt: "Investigate the send-order-receipt failures." },
};
describe("renderableActions", () => {
it("drops a navigate action whose target isn't a trigger:// URI", () => {
const actions: ActionsBlockAction[] = [
askAction,
{ label: "Runs", intent: { kind: "navigate", target: "/runs?status=FAILED" } },
];
expect(renderableActions(actions)).toEqual([askAction]);
});
it("keeps a navigate action with a canonical target", () => {
const navigate: ActionsBlockAction = {
label: "See its failed runs",
intent: { kind: "navigate", target: "trigger://proj_abc/env_abc/runs" },
};
expect(renderableActions([navigate])).toEqual([navigate]);
});
it("can filter every action out, leaving nothing to render", () => {
expect(
renderableActions([{ label: "Nowhere", intent: { kind: "navigate", target: "nope" } }])
).toEqual([]);
});
});
describe("keep digging, only while there is digging left", () => {
const card = { type: "data-view" };
const text = (t: string) => ({ type: "text", text: t });
it("sees the answer the turn went on to give", () => {
expect(answerContinuesAfter([card, text("so here is why")] as never, 0)).toBe(true);
});
it("leaves a card the turn ended on", () => {
expect(answerContinuesAfter([text("looking"), card] as never, 1)).toBe(false);
// An empty trailing text part is not an answer.
expect(answerContinuesAfter([card, text(" ")] as never, 0)).toBe(false);
});
});
describe("ActionsBlock", () => {
const source = readFileSync(new URL("./ActionsBlock.tsx", import.meta.url), "utf8");
it("hands the action's own intent to the host, and renders nothing without one", () => {
expect(source).toContain("onIntent(action.intent");
expect(source).toContain("if (!onIntent || renderable.length === 0) return null;");
});
it("filters through the shared filter rather than rendering every action", () => {
expect(source).toContain("renderableActions(block.actions)");
});
it("is a pure component: no app hooks, no server module, no Remix", () => {
expect(source).not.toMatch(/from\s+"~\/hooks\//);
expect(source).not.toMatch(/from\s+"@remix-run\//);
expect(source).not.toMatch(/\.server"/);
});
});
@@ -1,27 +0,0 @@
// A navigate target is a plain string at the contract boundary, so only targets
// that parse become buttons: a hallucinated URI costs a button, never a dead click.
import {
isTriggerUri,
type ActionsBlockAction,
type ChartAction,
} from "@internal/dashboard-agent-contracts";
type CardAction = ChartAction | ActionsBlockAction;
export function renderableActions<T extends CardAction>(actions: T[]): T[] {
return actions.filter((action) => {
const intent: CardAction["intent"] = action.intent;
return intent.kind !== "navigate" || isTriggerUri(intent.target);
});
}
/**
* "Keep digging" asks the agent to carry on — which is pointless once it already has.
* A turn that renders an inconclusive card and then keeps answering leaves the button
* offering work that is already done.
*/
export function answerContinuesAfter(parts: { type: string; text?: string }[], index: number) {
return parts
.slice(index + 1)
.some((part) => part.type === "text" && (part.text ?? "").trim().length > 0);
}
@@ -1,119 +0,0 @@
import { describe, expect, it } from "vitest";
import {
blockIdentity,
blockKey,
latestRevisionBlocks,
latestRevisionEntries,
} from "./view-blocks";
const enveloped = (id: string, revision: number, type = "diagnosis") => ({ type, id, revision });
describe("latestRevisionBlocks", () => {
it("keeps every block when none carry an envelope", () => {
const blocks = [{ type: "diagnosis" }, { type: "chart" }, { type: "diagnosis" }];
expect(latestRevisionBlocks(blocks)).toEqual(blocks);
});
it("collapses revisions of the same (type, id) to the highest revision", () => {
const blocks = [enveloped("d1", 1), enveloped("d1", 3), enveloped("d1", 2)];
expect(latestRevisionBlocks(blocks)).toEqual([enveloped("d1", 3)]);
});
it("keeps the last block on a revision tie", () => {
const first = { type: "diagnosis", id: "d1", revision: 2, summary: "old" };
const second = { type: "diagnosis", id: "d1", revision: 2, summary: "new" };
expect(latestRevisionBlocks([first, second])).toEqual([second]);
});
it("treats a missing revision as 0", () => {
const blocks = [{ type: "diagnosis", id: "d1" }, enveloped("d1", 1)];
expect(latestRevisionBlocks(blocks)).toEqual([enveloped("d1", 1)]);
});
it("does not group across types or ids", () => {
const blocks = [enveloped("d1", 1), enveloped("d1", 1, "chart"), enveloped("d2", 1)];
expect(latestRevisionBlocks(blocks)).toEqual(blocks);
});
it("keeps envelope-less blocks alongside collapsed ones, in order", () => {
const legacy = { type: "chart" };
const blocks = [enveloped("d1", 1), legacy, enveloped("d1", 2)];
expect(latestRevisionBlocks(blocks)).toEqual([legacy, enveloped("d1", 2)]);
});
it("collapses an investigation's revisions to the current one", () => {
const revision = (n: number, outcome: string) => ({
type: "investigation",
id: "inv_abc123",
revision: n,
version: 1,
investigation: { outcome },
});
const blocks = [
revision(0, "in_progress"),
revision(1, "in_progress"),
revision(2, "concluded"),
];
expect(latestRevisionBlocks(blocks)).toEqual([revision(2, "concluded")]);
});
it("keeps two different investigations apart", () => {
const blocks = [enveloped("inv_1", 1, "investigation"), enveloped("inv_2", 0, "investigation")];
expect(latestRevisionBlocks(blocks)).toEqual(blocks);
});
it("tolerates a non-array", () => {
expect(latestRevisionBlocks(undefined as unknown as unknown[])).toEqual([]);
});
});
/**
* The positions are what `ViewBlocks` keys envelope-less blocks on. Looking one up afterwards
* with `indexOf` answers with the first equal block, so two of them collide on one React key.
*/
describe("latestRevisionEntries", () => {
it("reports each survivor's position in the original array", () => {
const legacy = { type: "chart" };
const blocks = [enveloped("d1", 1), legacy, enveloped("d1", 2)];
expect(latestRevisionEntries(blocks)).toEqual([
{ block: legacy, index: 1 },
{ block: enveloped("d1", 2), index: 2 },
]);
});
it("gives two occurrences of the same block object distinct positions", () => {
const repeated = { type: "chart" };
const entries = latestRevisionEntries([repeated, repeated]);
expect(entries.map((entry) => entry.index)).toEqual([0, 1]);
expect(new Set(entries.map((entry) => blockKey(entry.block, entry.index))).size).toBe(2);
});
it("agrees with `latestRevisionBlocks` on which blocks survive", () => {
const blocks = [enveloped("d1", 1), { type: "chart" }, enveloped("d1", 2)];
expect(latestRevisionEntries(blocks).map((entry) => entry.block)).toEqual(
latestRevisionBlocks(blocks)
);
});
it("tolerates a non-array", () => {
expect(latestRevisionEntries(undefined as unknown as unknown[])).toEqual([]);
});
});
describe("blockIdentity / blockKey", () => {
it("identifies enveloped blocks by type and id", () => {
expect(blockIdentity(enveloped("d1", 1))).toBe("diagnosis::d1");
});
it("has no identity without a usable id", () => {
expect(blockIdentity({ type: "diagnosis" })).toBeUndefined();
expect(blockIdentity({ type: "diagnosis", id: "" })).toBeUndefined();
expect(blockIdentity({ id: "d1" })).toBeUndefined();
expect(blockIdentity(null)).toBeUndefined();
});
it("falls back to the index as the key", () => {
expect(blockKey({ type: "diagnosis" }, 2)).toBe("index:2");
expect(blockKey(enveloped("d1", 1), 2)).toBe("diagnosis::d1");
});
});
@@ -1,63 +0,0 @@
// Envelope is `blockEnvelopeSchema` in `@internal/dashboard-agent-contracts`. Blocks
// arrive as tool output, so every read here is defensive rather than typed.
type MaybeEnveloped = {
type?: unknown;
id?: unknown;
revision?: unknown;
};
// Undefined when the block has no envelope; such blocks are never grouped.
export function blockIdentity(block: unknown): string | undefined {
const { type, id } = (block ?? {}) as MaybeEnveloped;
if (typeof id !== "string" || id.length === 0) return undefined;
if (typeof type !== "string") return undefined;
return `${type}::${id}`;
}
function blockRevision(block: unknown): number {
const { revision } = (block ?? {}) as MaybeEnveloped;
return typeof revision === "number" && Number.isFinite(revision) ? revision : 0;
}
// Falls back to the array index: pre-envelope blocks have nothing stable to key on.
export function blockKey(block: unknown, index: number): string {
return blockIdentity(block) ?? `index:${index}`;
}
/**
* Latest-wins within one array only: highest `revision` at the winner's position, ties to the
* last. Blocks without an envelope are all kept, in order. Each survivor keeps the index it had
* in `blocks`, which is what an envelope-less block is keyed on — a search for it afterwards
* would answer with the first equal block, not this one.
*/
export function latestRevisionEntries<T>(blocks: readonly T[]): { block: T; index: number }[] {
if (!Array.isArray(blocks)) return [];
const winnerIndexByIdentity = new Map<string, number>();
blocks.forEach((block, index) => {
const identity = blockIdentity(block);
if (identity === undefined) return;
const currentWinner = winnerIndexByIdentity.get(identity);
if (
currentWinner === undefined ||
blockRevision(blocks[currentWinner]) <= blockRevision(block)
) {
winnerIndexByIdentity.set(identity, index);
}
});
const entries: { block: T; index: number }[] = [];
blocks.forEach((block, index) => {
const identity = blockIdentity(block);
if (identity === undefined || winnerIndexByIdentity.get(identity) === index) {
entries.push({ block, index });
}
});
return entries;
}
/** {@link latestRevisionEntries} without the positions. */
export function latestRevisionBlocks<T>(blocks: readonly T[]): T[] {
return latestRevisionEntries(blocks).map((entry) => entry.block);
}
@@ -1,107 +0,0 @@
/**
* Contract coverage, not a snapshot: every block the schema union allows must find a
* renderer in `ViewBlocks`. The block types are read off `viewBlockSchema`, so adding a
* union member without a `case` fails here instead of rendering an empty div in the panel.
*/
import { viewBlockSchema, type EnvelopedViewBlock } from "@internal/dashboard-agent-contracts";
import { isValidElement } from "react";
import { describe, expect, it } from "vitest";
import { ViewBlocks } from "./view-catalog";
const envelope = (id: string, revision = 0) => ({ id, revision, version: 1 });
// Typed by the union's discriminant: a new block type stops typechecking until it has one.
const FIXTURES: Record<EnvelopedViewBlock["type"], EnvelopedViewBlock> = {
diagnosis: {
...envelope("diagnosis-1"),
type: "diagnosis",
runId: "run_abc123",
summary: "The run failed on its last retry.",
category: "user_code_error",
likelyCause: "A null order id reaches the receipt builder.",
confidence: "high",
evidence: [{ type: "error", detail: "TypeError: cannot read id of null" }],
nextSteps: ["Guard the receipt builder against a missing order."],
},
chart: {
...envelope("chart-1"),
type: "chart",
query: "SELECT toStartOfHour(created_at) AS bucket, count() AS runs FROM runs",
chartType: "line",
xAxisColumn: "bucket",
yAxisColumns: ["runs"],
},
actions: {
...envelope("actions-1"),
type: "actions",
actions: [{ label: "See its failed runs", intent: { kind: "ask", prompt: "Show them" } }],
},
report: {
...envelope("report-1"),
type: "report",
revision: 0,
asOf: "2026-01-01T00:00:00.000Z",
vm: {
title: "health",
scope: "environment",
period: "24h",
generatedAt: "2026-01-01T00:00:00.000Z",
windowMinutes: 1440,
summary: { severity: "ok", statements: [] },
findings: [],
metrics: [],
facts: {},
links: [],
footer: [],
},
},
investigation: {
...envelope("investigation-1"),
type: "investigation",
investigation: {
outcome: "concluded",
severity: "crit",
confidence: "high",
title: "send-order-receipt fails on every retry",
headline: "Every attempt dies on a null order id.",
remediation: "Guard the receipt builder against a missing order.",
hypotheses: [],
evidence: [],
},
},
};
const blockTypes = viewBlockSchema.options.map((option) => option.shape.type.value);
function renderedChildren(blocks: EnvelopedViewBlock[]) {
const tree = ViewBlocks({ blocks, onIntent: () => {} });
expect(isValidElement(tree)).toBe(true);
const children = (tree as { props: { children: unknown } }).props.children;
return Array.isArray(children) ? children : [children];
}
describe("ViewBlocks covers the block contract", () => {
it("knows every type the schema union allows", () => {
expect(new Set(blockTypes)).toEqual(new Set(Object.keys(FIXTURES)));
});
it.each(blockTypes)("returns a renderer for a %s block", (type) => {
const fixture = FIXTURES[type as EnvelopedViewBlock["type"]];
expect(fixture, `no fixture for the ${type} block`).toBeDefined();
// Parsing first proves the fixture is a block the producer could really emit.
const block = viewBlockSchema.parse(fixture) as EnvelopedViewBlock;
const [rendered] = renderedChildren([block]);
expect(rendered, `ViewBlocks renders nothing for a ${type} block`).not.toBeNull();
expect(isValidElement(rendered)).toBe(true);
});
it("renders every block type together, in order", () => {
const blocks = blockTypes.map((type) =>
viewBlockSchema.parse(FIXTURES[type as EnvelopedViewBlock["type"]])
) as EnvelopedViewBlock[];
const rendered = renderedChildren(blocks);
expect(rendered).toHaveLength(blockTypes.length);
expect(rendered.every((node) => isValidElement(node))).toBe(true);
});
});
@@ -1,63 +1,25 @@
import type { AgentIntent, ViewBlock } from "@internal/dashboard-agent-contracts";
import { ActionsBlock } from "./ActionsBlock";
import type { ViewBlock } from "@internal/dashboard-agent";
import { AgentChart } from "./AgentChart";
import { InvestigationCard } from "./InvestigationCard";
import { ReportView, type ResolvedUri } from "./ReportView";
import { RunDiagnosisCard } from "./RunDiagnosisCard";
import { blockKey, latestRevisionEntries } from "./view-blocks";
// Unknown block types are skipped, so an older or newer agent cannot render
// arbitrary content. A new block needs a `case` here and a `viewBlockSchema` member.
export function ViewBlocks({
blocks,
onIntent,
resolveUri,
pagePaths,
answered = false,
}: {
blocks: ViewBlock[];
onIntent?: (intent: AgentIntent) => void;
resolveUri?: (uri: string) => ResolvedUri | null;
pagePaths?: Record<string, string>;
/** The turn kept answering after this card, so "keep digging" has nothing to ask for. */
answered?: boolean;
}) {
// The render registry for the dashboard agent's view catalog — our small
// "generative UI" layer. The agent emits a `render_view` tool call whose output
// is `{ blocks: ViewBlock[] }` (a spec drawn from the catalog defined in
// internal-packages/dashboard-agent). Here we map each block `type` to its
// component. Unknown types are skipped, so an older/newer agent can never
// render arbitrary content — same guarantee a generative-UI framework gives,
// without the dependency. Add a block by adding a `case` here and a union
// member in the package's `viewBlockSchema`.
export function ViewBlocks({ blocks }: { blocks: ViewBlock[] }) {
if (!Array.isArray(blocks)) return null;
return (
<div className="space-y-2">
{latestRevisionEntries(blocks).map(({ block, index }) => {
// The original array's index, so collapsing a revision above an
// envelope-less block can't shift its key.
const key = blockKey(block, index);
{blocks.map((block, i) => {
switch (block.type) {
case "diagnosis":
return <RunDiagnosisCard key={key} block={block} />;
return <RunDiagnosisCard key={i} block={block} />;
case "chart":
return <AgentChart key={key} block={block} />;
case "actions":
return <ActionsBlock key={key} block={block} onIntent={onIntent} />;
// Revisions share the investigationId, so latest-wins keeps one card.
case "investigation":
return (
<InvestigationCard
key={key}
block={block}
resolveUri={resolveUri}
onIntent={onIntent}
answered={answered}
/>
);
case "report":
return (
<ReportView
key={key}
vm={block.vm}
reportUri={block.reportUri}
onIntent={onIntent}
resolveUri={resolveUri}
pagePaths={pagePaths}
/>
);
return <AgentChart key={i} block={block} />;
default:
return null;
}
@@ -11,7 +11,6 @@ import { Header1, Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
AgentDotMatrix,
AgentMonoLogo,
DOT_MATRIX_PALETTES,
DOT_SHAPES,
EXTRA_FACE_SHAPES,
@@ -27,7 +26,7 @@ export default function Story() {
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex max-w-3xl flex-col gap-1">
<Header1>Trigger Agent Icons & Buttons</Header1>
<Header1>AI agent</Header1>
<Paragraph variant="small">
A resting logo that animates while the agent thinks. Each tab is a separate experiment.
</Paragraph>
@@ -60,6 +59,12 @@ export default function Story() {
// --- Dot matrix (5x5) ---------------------------------------------------------
// Dark-ink mono ramp for light surfaces (the built-in mono palette is white-based).
const LIGHT_MONO = {
stops: ["#0d0e12", "#1a1b1f", "#3b3e45"] as [string, string, string],
glow: "#1a1b1f",
};
function DotMatrixTab() {
return (
<div className="flex flex-col gap-6 py-6">
@@ -78,8 +83,8 @@ function DotMatrixTab() {
))}
</div>
<Paragraph variant="small" className="mt-2 -mb-3 max-w-3xl">
The <code className="text-text-bright">ask-trigger</code> Button variant. Mono logo, click
to think for 5s.
The <code className="text-text-bright">ask-ai</code> Button variant. Mono logo, click to
think for 5s.
</Paragraph>
<div className="flex flex-wrap items-center gap-6 rounded-md border border-grid-bright bg-background-bright px-6 py-5">
{(
@@ -90,7 +95,7 @@ function DotMatrixTab() {
] as [ButtonVariant, number, string][]
).map(([variant, matrixSize, label]) => (
<div key={variant} className="flex flex-col items-center gap-2">
<AskTriggerButton variant={variant} matrixSize={matrixSize} />
<AskAiButton variant={variant} matrixSize={matrixSize} />
<div className="text-[10px] uppercase tracking-wide text-text-dimmed">{label}</div>
</div>
))}
@@ -101,7 +106,7 @@ function DotMatrixTab() {
<div className="flex flex-wrap items-center gap-6 rounded-md border border-grid-bright bg-background-bright px-6 py-5">
{[14, 15, 16].map((s) => (
<div key={s} className="flex flex-col items-center gap-2">
<AskTriggerButton variant="ask-trigger/small" matrixSize={s} />
<AskAiButton variant="ask-trigger/small" matrixSize={s} />
<div className="text-[10px] uppercase tracking-wide text-text-dimmed">{s}px icon</div>
</div>
))}
@@ -128,10 +133,10 @@ function DotMatrixTab() {
/>
</div>
<div className="flex items-center gap-8 rounded-md border border-grid-bright bg-charcoal-100 px-6 py-5">
<AgentDotMatrix size={40} mode="light" palette="monoLight" restColor="#1a1b1f" />
<AgentDotMatrix size={40} mode="light" palette={LIGHT_MONO} restColor="#1a1b1f" />
<ToggleableMatrix
size={40}
matrix={{ mode: "light", palette: "monoLight", restColor: "#1a1b1f" }}
matrix={{ mode: "light", palette: LIGHT_MONO, restColor: "#1a1b1f" }}
/>
</div>
</div>
@@ -180,7 +185,7 @@ function DotMatrixTab() {
);
}
function AskTriggerButton({ variant, matrixSize }: { variant: ButtonVariant; matrixSize: number }) {
function AskAiButton({ variant, matrixSize }: { variant: ButtonVariant; matrixSize: number }) {
const [active, setActive] = useState(false);
const timeout = useRef<ReturnType<typeof setTimeout>>();
@@ -197,9 +202,17 @@ function AskTriggerButton({ variant, matrixSize }: { variant: ButtonVariant; mat
<Button
variant={variant}
onClick={trigger}
LeadingIcon={<AgentMonoLogo size={matrixSize} active={active} decorative />}
LeadingIcon={
<AgentDotMatrix
size={matrixSize}
active={active}
palette="mono"
restColor="#ffffff"
decorative
/>
}
>
Ask Trigger
Ask AI
</Button>
);
}
@@ -220,7 +233,16 @@ function FaceButton({ name }: { name: DotShapeName }) {
<Button
variant="ask-trigger/small"
onClick={trigger}
LeadingIcon={<AgentMonoLogo size={16} active={active} restShape={name} decorative />}
LeadingIcon={
<AgentDotMatrix
size={16}
active={active}
restShape={name}
palette="mono"
restColor="#ffffff"
decorative
/>
}
>
{name}
</Button>
@@ -1,161 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ANSI surface > paints the same layout (degraded) 1`] = `
"/report health <grey>prod · last 1h · vs your 7d normal</>
<red>✕</> Flow stalled — at your env concurrency limit <amber>for the last 38 min</>
concurrency 50/50 <green>▁</><green>▂</><green>▃</><green>▄</><green>▅</><amber>▆</><amber>▇</><amber>█</> 38 min at limit
pending 4,812 <amber>↑ 120×</> <green>▁</><green>▂</><green>▃</><green>▄</><green>▅</><amber>▆</><amber>▇</><amber>█</> <grey>(normal ~40)</>
start latency p95 43s <amber>↑ 6×</> <green>▁</><green>▂</><green>▃</><green>▄</><green>▅</><amber>▆</><amber>▇</><amber>█</> <grey>(normal ~7s)</>
throughput 180/min
done 820/min
triggered 1,000/min
<grey> why: 71% of pending is demo-email-sends</>
<grey> not your code — failures and durations normal</>
<grey> runs are finishing at ~820/min</>
<green>✓</> EXECUTION runs are executing normally
<green>✓</> LIVENESS fresh — telemetry current, updated 18s ago
<grey> read: limit saturated → incoming work exceeds capacity → backlog grows</>
<grey> NOT a code problem</>
→ Contact us to raise the limit
Read concurrency docs
or do nothing — backlog drains in ~26.7 min once triggers ease"
`;
exports[`ANSI surface > paints the same layout (empty) 1`] = `
"/report health <grey>prod · last 1h</>
<green>✓</> health ok
<green>✓</> data fresh
→ nothing to do"
`;
exports[`ANSI surface > paints the same layout (healthy) 1`] = `
"/report health <grey>prod · last 1h · vs your 7d normal</>
<green>✓</> Flow healthy — starting normally
start latency p95 6.8s <grey>→ flat</> <amber>▇</><amber>▆</><green>▁</><green>▅</><amber>█</><green>▂</><green>▃</><amber>█</> <grey>(normal ~7s)</>
pending 34 <grey>→ flat</> <amber>▇</><amber>▆</><green>▁</><green>▅</><amber>█</><green>▂</><green>▃</><amber>█</> <grey>(normal ~40)</>
throughput +12/min
done 842/min
triggered 830/min
<green>✓</> EXECUTION runs are executing normally
<green>✓</> LIVENESS fresh — telemetry current, updated 21s ago
<grey> read: runs are starting on time</>
<grey> runs are completing normally</>
→ nothing to do"
`;
exports[`ANSI surface > paints the same layout (untrustworthy) 1`] = `
"/report health <amber>⚑</> stale data <grey>prod · last 1h · vs your 7d normal</>
<red>✕</> Flow unknown — data stale
<amber>⚑</> The telemetry behind this report is stale, so the numbers below are informational only.
<red>✕</> EXECUTION execution can't be assessed — the telemetry is stale
<red>✕</> LIVENESS stale — no telemetry in 21m
liveness 21m
→ Check control plane"
`;
exports[`markdown surface > renders (degraded) 1`] = `
"/report health prod · last 1h · vs your 7d normal
🔴 Flow stalled — at your env concurrency limit for the last 38 min
concurrency 50/50 ▁▂▃▄▅▆▇█ 38 min at limit
pending 4,812 ↑ 120× ▁▂▃▄▅▆▇█ (normal ~40)
start latency p95 43s ↑ 6× ▁▂▃▄▅▆▇█ (normal ~7s)
throughput 180/min
done 820/min
triggered 1,000/min
why: 71% of pending is demo-email-sends
not your code — failures and durations normal
runs are finishing at ~820/min
🟢 EXECUTION runs are executing normally
🟢 LIVENESS fresh — telemetry current, updated 18s ago
read: limit saturated → incoming work exceeds capacity → backlog grows
NOT a code problem
→ Contact us to raise the limit
Read concurrency docs
or do nothing — backlog drains in ~26.7 min once triggers ease"
`;
exports[`markdown surface > renders (empty) 1`] = `
"/report health prod · last 1h
🟢 health ok
🟢 data fresh
→ nothing to do"
`;
exports[`markdown surface > renders (healthy) 1`] = `
"/report health prod · last 1h · vs your 7d normal
🟢 Flow healthy — starting normally
start latency p95 6.8s → flat ▇▆▁▅█▂▃█ (normal ~7s)
pending 34 → flat ▇▆▁▅█▂▃█ (normal ~40)
throughput +12/min
done 842/min
triggered 830/min
🟢 EXECUTION runs are executing normally
🟢 LIVENESS fresh — telemetry current, updated 21s ago
read: runs are starting on time
runs are completing normally
→ nothing to do"
`;
exports[`markdown surface > renders (untrustworthy) 1`] = `
"/report health 🚩 stale data prod · last 1h · vs your 7d normal
🔴 Flow unknown — data stale
🚩 The telemetry behind this report is stale, so the numbers below are informational only.
🔴 EXECUTION execution can't be assessed — the telemetry is stale
🔴 LIVENESS stale — no telemetry in 21m
liveness 21m
→ Check control plane"
`;