refactor(webapp): move dashboard-agent render UI to the UI PR
This commit is contained in:
@@ -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"
|
||||
`;
|
||||
Reference in New Issue
Block a user