From 2aeb32fa187ecb90f1007ca71c57cbef5394c1ce Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 11 Aug 2026 14:11:52 +0000 Subject: [PATCH] refactor(webapp): move dashboard-agent render UI to the UI PR --- .../dashboard-agent/ActionsBlock.tsx | 31 - .../DashboardAgentMessages.tsx | 131 +--- .../InvestigationCard.render.test.ts | 89 --- .../dashboard-agent/InvestigationCard.test.ts | 41 - .../dashboard-agent/InvestigationCard.tsx | 254 ------- .../dashboard-agent/ReportView.test.ts | 19 - .../components/dashboard-agent/ReportView.tsx | 426 ----------- .../dashboard-agent/agent-badges.tsx | 173 ----- .../components/dashboard-agent/agent-card.tsx | 44 -- .../dashboard-agent/chat-layout.tsx | 4 - .../investigation-winners.test.ts | 45 -- .../dashboard-agent/investigation-winners.ts | 14 - .../dashboard-agent/report-spark.test.ts | 116 --- .../dashboard-agent/report-spark.ts | 52 -- .../dashboard-agent/report-sparkline.tsx | 698 ------------------ .../dashboard-agent/view-actions.test.ts | 67 -- .../dashboard-agent/view-actions.ts | 27 - .../dashboard-agent/view-blocks.test.ts | 119 --- .../components/dashboard-agent/view-blocks.ts | 63 -- .../dashboard-agent/view-catalog.test.ts | 107 --- .../dashboard-agent/view-catalog.tsx | 64 +- .../app/routes/storybook.ai-agent/route.tsx | 46 +- .../reportRenderParity.test.ts.snap | 161 ---- 23 files changed, 65 insertions(+), 2726 deletions(-) delete mode 100644 apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx delete mode 100644 apps/webapp/app/components/dashboard-agent/InvestigationCard.render.test.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx delete mode 100644 apps/webapp/app/components/dashboard-agent/ReportView.test.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/ReportView.tsx delete mode 100644 apps/webapp/app/components/dashboard-agent/agent-badges.tsx delete mode 100644 apps/webapp/app/components/dashboard-agent/agent-card.tsx delete mode 100644 apps/webapp/app/components/dashboard-agent/chat-layout.tsx delete mode 100644 apps/webapp/app/components/dashboard-agent/investigation-winners.test.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/investigation-winners.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/report-spark.test.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/report-spark.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/report-sparkline.tsx delete mode 100644 apps/webapp/app/components/dashboard-agent/view-actions.test.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/view-actions.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/view-blocks.test.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/view-blocks.ts delete mode 100644 apps/webapp/app/components/dashboard-agent/view-catalog.test.ts delete mode 100644 apps/webapp/test/__snapshots__/reportRenderParity.test.ts.snap diff --git a/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx b/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx deleted file mode 100644 index 4ef3a276b..000000000 --- a/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx +++ /dev/null @@ -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 ( - - {renderable.map((action, i) => ( - - ))} - - ); -} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx index 955464b87..1d02bf466 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx @@ -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(); - +// 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 { - const best = new Map(); - 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 { - const previous = useRef>(); - const next = useMemo(() => winningInvestigationOccurrences(messages), [messages]); - previous.current = reuseWinners(previous.current, next); - return previous.current; -} - -function withoutSupersededInvestigations( - blocks: unknown[], - occurrence: string, - winners: Map | 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; }) { - if (message.role !== "assistant" || !message.parts?.some((p) => viewBlocksFor(p))) { + if (message.role !== "assistant" || !message.parts?.some((p) => viewSpecFor(p))) { return ; } return (
{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 ( - - ); + const spec = viewSpecFor(part); + if (spec) return ; + return renderPart(part, i); })}
); -} - -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 (
- {stripped.map((message) => ( - + {messages.map((message) => ( + ))} {isThinking && (
diff --git a/apps/webapp/app/components/dashboard-agent/InvestigationCard.render.test.ts b/apps/webapp/app/components/dashboard-agent/InvestigationCard.render.test.ts deleted file mode 100644 index c81dde8fd..000000000 --- a/apps/webapp/app/components/dashboard-agent/InvestigationCard.render.test.ts +++ /dev/null @@ -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["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[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"); - }); -}); diff --git a/apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts b/apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts deleted file mode 100644 index b6a126ced..000000000 --- a/apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts +++ /dev/null @@ -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;/); - }); -}); diff --git a/apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx b/apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx deleted file mode 100644 index 535f65e00..000000000 --- a/apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx +++ /dev/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 = { - info: "Info", - warn: "Degraded", - crit: "Critical", -}; - -const VERDICT_LABELS: Record = { - testing: "Testing", - validated: "Validated", - invalidated: "Ruled out", -}; - -type ResolveUri = (uri: string) => ResolvedUri | null; - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} - -function EvidenceItem({ - evidence, - stacked, - resolveUri, -}: { - evidence: Evidence; - stacked?: boolean; - resolveUri?: ResolveUri; -}) { - const resolved = resolveUri?.(evidence.uri) ?? null; - return ( -
  • - {/* The Badge primitive is a grid, so `w-fit` is needed to stop it stretching. */} - {evidence.kind} -
    -

    {evidence.label}

    - {resolved ? ( - - {resolved.label} - - ) : ( -
    {evidence.uri}
    - )} - {evidence.excerpt ? ( -
    -            {evidence.excerpt}
    -          
    - ) : null} -
    -
  • - ); -} - -function HypothesisRow({ - hypothesis, - resolveUri, -}: { - hypothesis: InvestigationHypothesis; - resolveUri?: ResolveUri; -}) { - return ( -
  • -
    - - {VERDICT_LABELS[hypothesis.verdict]} - -
    -

    {hypothesis.statement}

    - {hypothesis.finding ?

    {hypothesis.finding}

    : null} - {hypothesis.evidence.length > 0 ? ( -
      - {hypothesis.evidence.map((evidence, i) => ( - - ))} -
    - ) : null} -
  • - ); -} - -function InvestigationActions({ - actions, - onIntent, -}: { - actions: InvestigationAction[]; - onIntent?: (intent: AgentIntent) => void; -}) { - if (!onIntent || actions.length === 0) return null; - return ( -
    - - {actions.map((action, i) => ( - - ))} - -
    - ); -} - -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 ( - - -
    - Investigation - - {SEVERITY_LABELS[investigation.severity]} - - -
    - {investigation.runId ? ( -
    {investigation.runId}
    - ) : null} -
    - - -

    {investigation.title}

    - -
    -

    {investigation.headline}

    -
    - - {/* The schema makes `remediation` and `checkNext` mutually exclusive. */} - {concluded && investigation.remediation ? ( -
    -

    {investigation.remediation}

    -
    - ) : null} - - {investigation.checkNext && investigation.checkNext.length > 0 ? ( -
    -
      - {investigation.checkNext.map((item, i) => ( -
    1. - {item} -
    2. - ))} -
    -
    - ) : null} - - {investigation.caveat ? ( - {investigation.caveat.message} - ) : null} - -
    - - - {expanded ? ( -
    - {investigation.hypotheses.length > 0 ? ( -
    -
      - {investigation.hypotheses.map((hypothesis) => ( - - ))} -
    -
    - ) : null} - - {investigation.evidence.length > 0 ? ( -
    -
      - {investigation.evidence.map((evidence, i) => ( - - ))} -
    -
    - ) : null} -
    - ) : null} -
    - - !answered || action.kind !== "ask_follow_up" - )} - onIntent={onIntent} - /> -
    -
    - ); -} diff --git a/apps/webapp/app/components/dashboard-agent/ReportView.test.ts b/apps/webapp/app/components/dashboard-agent/ReportView.test.ts deleted file mode 100644 index 5a4ce833e..000000000 --- a/apps/webapp/app/components/dashboard-agent/ReportView.test.ts +++ /dev/null @@ -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*\(/); - }); -}); diff --git a/apps/webapp/app/components/dashboard-agent/ReportView.tsx b/apps/webapp/app/components/dashboard-agent/ReportView.tsx deleted file mode 100644 index ae5eff78f..000000000 --- a/apps/webapp/app/components/dashboard-agent/ReportView.tsx +++ /dev/null @@ -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 = { 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 {label}; - - // 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 {label}; - } - - // 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 ( - - {label} - - ); - } - return {label}; - } - - 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 ( - - {label} - - ); - } - return {label}; - } - - // 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 {label}; - } - - const intent: AgentIntent = - target.kind === "resource" - ? { kind: "navigate", target: target.uri } - : { kind: "ask", prompt: `How do I ${lowerFirst(label)}?` }; - - if (!onIntent) return {label}; - - return onIntent(intent)}>{label}; -} - -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 = { - 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 = { - 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 = { - 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 ( - 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 ( -
    - - {finding.metrics.map((row) => ( - - ))} - - - - {finding.why.map((line, i) => ( - - ))} - -
    - ); -} - -// --- 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; -}) { - 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" ? ( - - {link.label} - - ) : ( - - {link.label} - - ), - }); - } else if (target.kind === "resource" && target.resolved) { - footerItems.push({ - code: link.key, - node: ( - {target.resolved.label} - ), - }); - } - } - - return ( - - - {layout.trust ? {layout.trust.badge} : null} - - - - - - {layout.trust ?

    {layout.trust.note}

    : null} - - {layout.hero && layout.hero.expanded ? ( - - ) : null} - - {layout.findings.length > 0 || layout.statements.length > 0 ? ( -
    - {layout.findings.map((finding, i) => ( -
    - - {finding.expanded ? ( -
    - -
    - ) : null} -
    - ))} - {layout.statements.map((statement, i) => ( -

    - - {statement.text} -

    - ))} -
    - ) : null} - - - {layout.reads.map((read, i) => ( - - ))} - - - - - {reportUri ? : null} -
    -
    - ); -} diff --git a/apps/webapp/app/components/dashboard-agent/agent-badges.tsx b/apps/webapp/app/components/dashboard-agent/agent-badges.tsx deleted file mode 100644 index abf46403d..000000000 --- a/apps/webapp/app/components/dashboard-agent/agent-badges.tsx +++ /dev/null @@ -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 = { - 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 = { - 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 ( - span]:flex [&>span]:items-center [&>span]:gap-1", - TONE_BADGE[tone], - className - )} - > - {Icon ? : null} - {children} - - ); -} - -export function CategoryBadge({ - className, - children, -}: { - className?: string; - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} - -export type AgentConfidence = "high" | "medium" | "low"; - -const CONFIDENCE_TONE: Record = { - high: "success", - medium: "warning", - low: "neutral", -}; - -const CONFIDENCE_ICON: Record = { - high: CheckCircleIcon, - medium: ExclamationTriangleIcon, - low: QuestionMarkCircleIcon, -}; - -const CONFIDENCE_LABEL: Record = { - high: "High confidence", - medium: "Medium confidence", - low: "Low confidence", -}; - -export function ConfidenceBadge({ confidence }: { confidence: AgentConfidence }) { - return ( - - {CONFIDENCE_LABEL[confidence]} - - ); -} - -export type AgentSeverity = "info" | "warn" | "crit"; - -const SEVERITY_TONE: Record = { - info: "neutral", - warn: "warning", - crit: "error", -}; - -const SEVERITY_ICON: Record = { - info: InformationCircleIcon, - warn: ExclamationTriangleIcon, - crit: ExclamationCircleIcon, -}; - -export function SeverityBadge({ - severity, - children, -}: { - severity: AgentSeverity; - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} - -export type AgentVerdict = "testing" | "validated" | "invalidated"; - -const VERDICT_TONE: Record = { - testing: "neutral", - validated: "success", - invalidated: "neutral", -}; - -const VERDICT_ICON: Record = { - testing: undefined, - validated: CheckCircleIcon, - invalidated: NoSymbolIcon, -}; - -export function VerdictBadge({ - verdict, - children, -}: { - verdict: AgentVerdict; - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} - -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 ; -} diff --git a/apps/webapp/app/components/dashboard-agent/agent-card.tsx b/apps/webapp/app/components/dashboard-agent/agent-card.tsx deleted file mode 100644 index ddd5a3a72..000000000 --- a/apps/webapp/app/components/dashboard-agent/agent-card.tsx +++ /dev/null @@ -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 = { - 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
    {children}
    ; -} - -/** The card's top strip. `className` carries its own layout, never its inset. */ -export function AgentCardHeader({ - className, - children, -}: { - className?: string; - children: ReactNode; -}) { - return
    {children}
    ; -} - -export function AgentCardBody({ - density = "compact", - className, - children, -}: { - density?: AgentCardDensity; - className?: string; - children: ReactNode; -}) { - return
    {children}
    ; -} diff --git a/apps/webapp/app/components/dashboard-agent/chat-layout.tsx b/apps/webapp/app/components/dashboard-agent/chat-layout.tsx deleted file mode 100644 index 0dfd401e0..000000000 --- a/apps/webapp/app/components/dashboard-agent/chat-layout.tsx +++ /dev/null @@ -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
    {children}
    ; -} diff --git a/apps/webapp/app/components/dashboard-agent/investigation-winners.test.ts b/apps/webapp/app/components/dashboard-agent/investigation-winners.test.ts deleted file mode 100644 index 71eea76c3..000000000 --- a/apps/webapp/app/components/dashboard-agent/investigation-winners.test.ts +++ /dev/null @@ -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)"); - }); -}); diff --git a/apps/webapp/app/components/dashboard-agent/investigation-winners.ts b/apps/webapp/app/components/dashboard-agent/investigation-winners.ts deleted file mode 100644 index 5d430e434..000000000 --- a/apps/webapp/app/components/dashboard-agent/investigation-winners.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function sameOccurrences(a: Map, b: Map): 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 | undefined, - next: Map -): Map { - return previous && sameOccurrences(previous, next) ? previous : next; -} diff --git a/apps/webapp/app/components/dashboard-agent/report-spark.test.ts b/apps/webapp/app/components/dashboard-agent/report-spark.test.ts deleted file mode 100644 index 6fd476409..000000000 --- a/apps/webapp/app/components/dashboard-agent/report-spark.test.ts +++ /dev/null @@ -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*\)/); - }); -}); diff --git a/apps/webapp/app/components/dashboard-agent/report-spark.ts b/apps/webapp/app/components/dashboard-agent/report-spark.ts deleted file mode 100644 index 79dd79a7c..000000000 --- a/apps/webapp/app/components/dashboard-agent/report-spark.ts +++ /dev/null @@ -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))); -} diff --git a/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx b/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx deleted file mode 100644 index 96e6eb709..000000000 --- a/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx +++ /dev/null @@ -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 = { - 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 = { - ok: "var(--color-success)", - warn: "var(--color-warning)", - crit: "var(--color-error)", - neutral: "var(--color-text-dimmed)", -}; - -const SEVERITY_TONE: Record = { - 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 ( - - ); -} - -// --- card chrome ------------------------------------------------------------ - -export function ReportCard({ children }: { children: ReactNode }) { - return {children}; -} - -/** - * 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 ( - - {name} - {children} - {meta} - - ); -} - -/** The card body. */ -export function ReportBody({ children, dimmed }: { children: ReactNode; dimmed?: boolean }) { - return {children}; -} - -/** 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 ( -

    - - - {phrase} - {continuation ? — {continuation} : null} - -

    - ); -} - -/** - * 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 ( -

    - - {type} - - {text} - -

    - ); -} - -// --- 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 ( - - {segment.text} - - ); - case "entity": - return ( - - {segment.text} - - ); - case "verdict": - return ( - - {segment.text} - - ); - default: - return {segment.text}; - } - })} - - ); -} - -/** - * 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 ( -
    - {label} -
    - {lines.map((line, i) => ( -

    {line}

    - ))} -
    -
    - ); -} - -// --- 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 = { - 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 ( -
    -

    - {REPORT_LABELS.nextSteps} -

    - {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. -
    - {row.map((item, i) => ( - {item.node} - ))} -
    - ) : null} - {rest.length > 0 || noteLines.length > 0 ? ( -

    - {noteLines.join(" ")} - {noteLines.length > 0 && rest.length > 0 ? " " : null} - {rest.map((item, i) => ( - - {i > 0 ? " " : null} - {item.node} - - ))} -

    - ) : null} -
    - ); -} - -/** - * 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 ( - - {children} - {external ? ( - - ) : null} - - ); -} - -/** A footer entry that states an option instead of offering one. */ -export function ReportFooterNote({ children }: { children: ReactNode }) { - return {children}; -} - -/** - * 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 ( - - - - ); -} - -/** 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 ( - - - {children} - - - ); -} - -/** Where the snapshot came from, in the report's own URI vocabulary. */ -export function ReportProvenance({ uri }: { uri: string }) { - return
    {uri}
    ; -} - -// --- 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 & { formatPoint: (value: number) => string }) { - if (!active || !payload || payload.length === 0) return null; - const entry = payload[0].payload as ReportSparkDatum; - return ( - -
    - {entry.date ? ( - - {formatDateTime(entry.date, "UTC", [], false, true)} - - ) : null} -
    {formatPoint(entry.count)}
    - {entry.hot ?
    in the anomaly window
    : null} -
    -
    - ); -} - -/** - * 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 ( -
    - } - > - - {data.map((entry, i) => ( - - ))} - - -
    - ); -} - -// --- 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 ( - <> -
  • - {label} - - - {value} - - {note || heroNote ? ( - - ) : null} - - - {delta?.text ?? ""} - - {series && series.length > 0 ? ( - - ) : ( - // Keeps the column occupied so a series-less metric doesn't pull the - // rows out of alignment. - - )} -
  • - - {(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. -
  • - {/* Indented under the parent label, shallow enough to stay inside the - 6rem label column. */} - {sub.label} - - {sub.value} - -
  • - ))} - - ); -} - -/** 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
      {children}
    ; -} diff --git a/apps/webapp/app/components/dashboard-agent/view-actions.test.ts b/apps/webapp/app/components/dashboard-agent/view-actions.test.ts deleted file mode 100644 index a9914da14..000000000 --- a/apps/webapp/app/components/dashboard-agent/view-actions.test.ts +++ /dev/null @@ -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"/); - }); -}); diff --git a/apps/webapp/app/components/dashboard-agent/view-actions.ts b/apps/webapp/app/components/dashboard-agent/view-actions.ts deleted file mode 100644 index 1d59d20b0..000000000 --- a/apps/webapp/app/components/dashboard-agent/view-actions.ts +++ /dev/null @@ -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(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); -} diff --git a/apps/webapp/app/components/dashboard-agent/view-blocks.test.ts b/apps/webapp/app/components/dashboard-agent/view-blocks.test.ts deleted file mode 100644 index 9df716b9e..000000000 --- a/apps/webapp/app/components/dashboard-agent/view-blocks.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/apps/webapp/app/components/dashboard-agent/view-blocks.ts b/apps/webapp/app/components/dashboard-agent/view-blocks.ts deleted file mode 100644 index 1b7379bef..000000000 --- a/apps/webapp/app/components/dashboard-agent/view-blocks.ts +++ /dev/null @@ -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(blocks: readonly T[]): { block: T; index: number }[] { - if (!Array.isArray(blocks)) return []; - - const winnerIndexByIdentity = new Map(); - 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(blocks: readonly T[]): T[] { - return latestRevisionEntries(blocks).map((entry) => entry.block); -} diff --git a/apps/webapp/app/components/dashboard-agent/view-catalog.test.ts b/apps/webapp/app/components/dashboard-agent/view-catalog.test.ts deleted file mode 100644 index 8b36e0c3f..000000000 --- a/apps/webapp/app/components/dashboard-agent/view-catalog.test.ts +++ /dev/null @@ -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 = { - 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); - }); -}); diff --git a/apps/webapp/app/components/dashboard-agent/view-catalog.tsx b/apps/webapp/app/components/dashboard-agent/view-catalog.tsx index 2e0887703..ab9fdbb13 100644 --- a/apps/webapp/app/components/dashboard-agent/view-catalog.tsx +++ b/apps/webapp/app/components/dashboard-agent/view-catalog.tsx @@ -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; - /** 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 (
    - {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 ; + return ; case "chart": - return ; - case "actions": - return ; - // Revisions share the investigationId, so latest-wins keeps one card. - case "investigation": - return ( - - ); - case "report": - return ( - - ); + return ; default: return null; } diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index f69a43c57..010f9ac65 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -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 (
    - Trigger Agent — Icons & Buttons + AI agent A resting logo that animates while the agent thinks. Each tab is a separate experiment. @@ -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 (
    @@ -78,8 +83,8 @@ function DotMatrixTab() { ))}
    - The ask-trigger Button variant. Mono logo, click - to think for 5s. + The ask-ai Button variant. Mono logo, click to + think for 5s.
    {( @@ -90,7 +95,7 @@ function DotMatrixTab() { ] as [ButtonVariant, number, string][] ).map(([variant, matrixSize, label]) => (
    - +
    {label}
    ))} @@ -101,7 +106,7 @@ function DotMatrixTab() {
    {[14, 15, 16].map((s) => (
    - +
    {s}px icon
    ))} @@ -128,10 +133,10 @@ function DotMatrixTab() { />
    - +
    @@ -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>(); @@ -197,9 +202,17 @@ function AskTriggerButton({ variant, matrixSize }: { variant: ButtonVariant; mat ); } @@ -220,7 +233,16 @@ function FaceButton({ name }: { name: DotShapeName }) { diff --git a/apps/webapp/test/__snapshots__/reportRenderParity.test.ts.snap b/apps/webapp/test/__snapshots__/reportRenderParity.test.ts.snap deleted file mode 100644 index 9ba7e1dc7..000000000 --- a/apps/webapp/test/__snapshots__/reportRenderParity.test.ts.snap +++ /dev/null @@ -1,161 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`ANSI surface > paints the same layout (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[`ANSI surface > paints the same layout (empty) 1`] = ` -"/report health prod · last 1h - -✓ health ok - -✓ data fresh - -→ nothing to do" -`; - -exports[`ANSI surface > paints the same layout (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[`ANSI surface > paints the same layout (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" -`; - -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" -`;