diff --git a/.changeset/mint-token-cap-help.md b/.changeset/mint-token-cap-help.md new file mode 100644 index 00000000000..432bd34413c --- /dev/null +++ b/.changeset/mint-token-cap-help.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +`trigger mint-token --help` now says that a token minted without `--cap` is read-only, instead of claiming it carries your full role. diff --git a/.changeset/report-json-and-period-units.md b/.changeset/report-json-and-period-units.md new file mode 100644 index 00000000000..5567d2a5b9b --- /dev/null +++ b/.changeset/report-json-and-period-units.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Reports can be fetched as structured data with the `json` format. The shortest report period is now one minute (`30m`, `1h`, `7d`). diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..a791b1a73e5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Generated, not hand-written: collapsed in diffs and excluded from language stats. +internal-packages/dashboard-agent-db/drizzle/meta/*.json linguist-generated=true +**/__snapshots__/*.snap linguist-generated=true diff --git a/.gitignore b/.gitignore index f540927e32b..b11dded2b02 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,5 @@ ailogger-output.log # observability-map CLI output artifact, not committed observability-map.json + +.claude/worktrees/ diff --git a/.server-changes/dashboard-agent.md b/.server-changes/dashboard-agent.md new file mode 100644 index 00000000000..eaadfafe915 --- /dev/null +++ b/.server-changes/dashboard-agent.md @@ -0,0 +1,16 @@ +--- +area: webapp +type: feature +--- + +Meet the dashboard agent: a chat in every environment that answers questions about your runs, queues, errors and health with real data and links. It takes over from Ask AI everywhere that used to appear; on the Free plan you get 20 messages. + +**Investigate** on a failed run, an error, a backed-up queue or a run that hasn't started gets you a worked-through answer — what happened, why, and how to fix it, with every claim linked to the runs, errors and deploys behind it. + +The agent works on preview and dev branches, with that branch's own data. + +The health report reads the same everywhere — dashboard, terminal, editor. A very long chat keeps working: the agent summarises the earlier part and carries on. The agent's replies no longer show images. + +A sample of conversations is scored automatically so the agent keeps getting better. Only the score and a one-line summary are kept, never your messages, data or code, and we can switch it off for your organization on request. + +Separately: a queue's wait times, peak depth, throughput and throttling can now be read from the API, and the Docs button has been removed from page headers. diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore index 595ab180e15..f825411d640 100644 --- a/apps/webapp/.gitignore +++ b/apps/webapp/.gitignore @@ -7,6 +7,9 @@ node_modules /cypress/screenshots /cypress/videos +# Output of `pnpm run agent-ui:screenshots` +/screenshots + /app/styles/tailwind.css # Ensure the .env symlink is not removed by accident @@ -20,4 +23,4 @@ storybook-static /prisma/seed.js /prisma/populate.js -.memory-snapshots \ No newline at end of file +.memory-snapshots diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index d61ea0055fa..389d5e9e569 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -1,3 +1,9 @@ +/** + * @deprecated Superseded by the dashboard agent (`components/dashboard-agent`). Nothing mounts + * this any more — every Ask AI entry point now opens Ask Trigger. Kept until the agent has + * shipped, then removed along with `@kapaai/react-sdk` and `KAPA_AI_WEBSITE_ID`. + */ + import { ArrowPathIcon, ArrowUpIcon, @@ -81,6 +87,8 @@ function useAskAIState() { * it around the popover, not inside, so the dialog and shortcut survive the popover closing. * `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no * Kapa website id, or SSR). + * + * @deprecated See the note at the top of this file. */ export function AskAIRoot({ children, @@ -137,6 +145,7 @@ function AskAIRootProvider({ ); } +/** @deprecated See the note at the top of this file. */ export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { const { isManagedCloud } = useFeatures(); const websiteId = useKapaWebsiteId(); diff --git a/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx b/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx new file mode 100644 index 00000000000..4ef3a276b74 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx @@ -0,0 +1,31 @@ +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 1d02bf46621..2ec0174f8bd 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx @@ -23,14 +23,65 @@ 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; +} + +function investigationBlocksFor(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. */ +export function winningInvestigationOccurrences(messages: UIMessage[]): Map { + const best = new Map(); + for (const message of messages) { + (message.parts ?? []).forEach((part, partIndex) => { + for (const block of investigationBlocksFor(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])); +} + +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 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) => viewSpecFor(p))) { return ; @@ -39,8 +90,14 @@ const DashboardAgentMessageBubble = memo(function DashboardAgentMessageBubble({
{message.parts.map((part, i) => { const spec = viewSpecFor(part); - if (spec) return ; - return renderPart(part, i); + if (!spec) return renderPart(part, i); + const blocks = withoutSupersededInvestigations( + spec.blocks, + `${message.id}:${i}`, + investigationWinners + ); + if (blocks.length === 0) return null; + return ; })}
); @@ -60,12 +117,17 @@ export function DashboardAgentMessages({ error?: Error; }) { const rootRef = useAutoScrollToBottom([messages, isThinking]); + const investigationWinners = winningInvestigationOccurrences(messages); return (
{messages.map((message) => ( - + ))} {isThinking && (
diff --git a/apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts b/apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts new file mode 100644 index 00000000000..b6a126cedeb --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts @@ -0,0 +1,41 @@ +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 new file mode 100644 index 00000000000..5ed74e5fac3 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx @@ -0,0 +1,252 @@ +// `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.map((hypothesis) => ( + + ))} +
    +
    + + {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 new file mode 100644 index 00000000000..5a4ce833e8a --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/ReportView.test.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 00000000000..ae5eff78f88 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/ReportView.tsx @@ -0,0 +1,426 @@ +/** + * 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 new file mode 100644 index 00000000000..abf46403d70 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/agent-badges.tsx @@ -0,0 +1,173 @@ +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 new file mode 100644 index 00000000000..ddd5a3a728f --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/agent-card.tsx @@ -0,0 +1,44 @@ +// 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 new file mode 100644 index 00000000000..0dfd401e0c5 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/chat-layout.tsx @@ -0,0 +1,4 @@ +// 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/message-limits.test.ts b/apps/webapp/app/components/dashboard-agent/message-limits.test.ts new file mode 100644 index 00000000000..acc51e14302 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/message-limits.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + checkMessageParts, + declaredBodyBytes, + exceedsMessageBodyBytes, + MAX_MESSAGE_BODY_BYTES, + MAX_MESSAGE_CHARS, + MAX_MESSAGE_PARTS, +} from "./message-limits"; + +describe("message limits", () => { + it("lets a long real question through", () => { + const text = "why did this fail?\n".repeat(50); + + expect(exceedsMessageBodyBytes(Buffer.byteLength(text, "utf8"))).toBe(false); + expect(checkMessageParts([{ type: "text", text }])).toBeNull(); + }); + + it("refuses a pasted dump by bytes", () => { + expect(exceedsMessageBodyBytes(MAX_MESSAGE_BODY_BYTES)).toBe(false); + expect(exceedsMessageBodyBytes(MAX_MESSAGE_BODY_BYTES + 1)).toBe(true); + }); + + it("counts multi-byte characters as bytes, not characters", () => { + // Under the char cap, over the byte cap: 4 bytes each. + const emoji = "🙂".repeat(MAX_MESSAGE_BODY_BYTES / 4 + 1); + + expect(emoji.length).toBeLessThan(MAX_MESSAGE_BODY_BYTES); + expect(exceedsMessageBodyBytes(Buffer.byteLength(emoji, "utf8"))).toBe(true); + }); + + it("refuses a dump split across parts", () => { + const parts = Array.from({ length: 4 }, () => ({ + type: "text", + text: "x".repeat(MAX_MESSAGE_CHARS / 2), + })); + + expect(checkMessageParts(parts)).toBe("too_long"); + }); + + it("refuses too many parts", () => { + const parts = Array.from({ length: MAX_MESSAGE_PARTS + 1 }, () => ({ + type: "text", + text: "x", + })); + + expect(checkMessageParts(parts)).toBe("too_many_parts"); + expect(checkMessageParts(parts.slice(0, MAX_MESSAGE_PARTS))).toBeNull(); + }); + + it("leaves a shape that isn't a parts array to the schema", () => { + expect(checkMessageParts(undefined)).toBeNull(); + expect(checkMessageParts("nope")).toBeNull(); + }); + + it("reads the declared size, or nothing when it isn't declared", () => { + expect(declaredBodyBytes(new Headers({ "content-length": "1234" }))).toBe(1234); + expect(declaredBodyBytes(new Headers())).toBeNull(); + expect(declaredBodyBytes(new Headers({ "content-length": "nope" }))).toBeNull(); + // An undeclared size can't be refused here; the body's own length is. + expect(exceedsMessageBodyBytes(null)).toBe(false); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/message-limits.ts b/apps/webapp/app/components/dashboard-agent/message-limits.ts new file mode 100644 index 00000000000..fe395993884 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/message-limits.ts @@ -0,0 +1,51 @@ +/** + * Caps on one message to the agent, shared by the composer and the two server paths a message + * can arrive through. Generous for a real question with a pasted stack trace, stingy for a dump: + * an unbounded paste is a large model bill and a permanently fat transcript. + */ + +/** ~2 pages of text, or a long stack trace. */ +export const MAX_MESSAGE_CHARS = 8_000; + +/** The counter only shows near the limit, so a normal message never sees it. */ +export const MESSAGE_CHARS_WARN_AT = Math.floor(MAX_MESSAGE_CHARS * 0.9); + +/** A composed message is a handful of parts; dozens means something is wrong. */ +export const MAX_MESSAGE_PARTS = 20; + +/** + * The whole request body, in bytes: headroom for {@link MAX_MESSAGE_CHARS} of any script plus + * the per-turn metadata, and nothing like a pasted file. + */ +export const MAX_MESSAGE_BODY_BYTES = 64 * 1024; + +export const MESSAGE_TOO_LARGE_CODE = "message_too_large"; + +export const MESSAGE_TOO_LARGE_ERROR = "That message is too long. Shorten it and send again."; + +export type MessagePartsProblem = "too_many_parts" | "too_long"; + +/** Counts the parts and their text. Anything that isn't a parts array is left to the schema. */ +export function checkMessageParts(parts: unknown): MessagePartsProblem | null { + if (!Array.isArray(parts)) return null; + if (parts.length > MAX_MESSAGE_PARTS) return "too_many_parts"; + + let chars = 0; + for (const part of parts) { + const text = (part as { text?: unknown } | null)?.text; + if (typeof text === "string") chars += text.length; + } + return chars > MAX_MESSAGE_CHARS ? "too_long" : null; +} + +/** The declared body size, or null when the client didn't declare one. */ +export function declaredBodyBytes(headers: Headers): number | null { + const raw = headers.get("content-length"); + if (!raw) return null; + const bytes = Number.parseInt(raw, 10); + return Number.isFinite(bytes) ? bytes : null; +} + +export function exceedsMessageBodyBytes(bytes: number | null | undefined): boolean { + return typeof bytes === "number" && bytes > MAX_MESSAGE_BODY_BYTES; +} diff --git a/apps/webapp/app/components/dashboard-agent/report-spark.test.ts b/apps/webapp/app/components/dashboard-agent/report-spark.test.ts new file mode 100644 index 00000000000..6fd476409b2 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/report-spark.test.ts @@ -0,0 +1,116 @@ +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 new file mode 100644 index 00000000000..79dd79a7cd6 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/report-spark.ts @@ -0,0 +1,52 @@ +/** + * 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 new file mode 100644 index 00000000000..eb1c6a84c1a --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx @@ -0,0 +1,694 @@ +/** + * 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-[6.5rem]"; + +/** The chart's own width; the trailing peak label uses the column's remainder. */ +const SPARK_WIDTH = 72; + +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-[7rem_minmax(0,1fr)_2.75rem_6.5rem] items-center gap-x-2 @max-[19rem]:grid-cols-[7rem_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 is sized for the longest one 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 + 7rem 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/resolve-uris.test.ts b/apps/webapp/app/components/dashboard-agent/resolve-uris.test.ts new file mode 100644 index 00000000000..c7b93a0b522 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/resolve-uris.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { MAX_URIS_PER_RESOLVE_REQUEST, planUriBatches } from "./resolve-uris"; + +const uri = (index: number) => `trigger://runs/run_${index}`; + +describe("planUriBatches", () => { + it("resolves a card's twenty citations in one request", () => { + const batches = planUriBatches(Array.from({ length: 20 }, (_, index) => uri(index))); + + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(20); + }); + + it("asks about each URI once", () => { + const batches = planUriBatches([uri(1), uri(1), uri(2)]); + + expect(batches).toEqual([[uri(1), uri(2)]]); + }); + + it("caps a request and carries the rest over", () => { + const count = MAX_URIS_PER_RESOLVE_REQUEST + 3; + const batches = planUriBatches(Array.from({ length: count }, (_, index) => uri(index))); + + expect(batches).toHaveLength(2); + expect(batches[0]).toHaveLength(MAX_URIS_PER_RESOLVE_REQUEST); + expect(batches[1]).toHaveLength(3); + }); + + it("has nothing to send for nothing", () => { + expect(planUriBatches([])).toEqual([]); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/resolve-uris.ts b/apps/webapp/app/components/dashboard-agent/resolve-uris.ts new file mode 100644 index 00000000000..030510701fe --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/resolve-uris.ts @@ -0,0 +1,25 @@ +/** + * Batching for `trigger://` resolution. An investigation card cites ten to twenty targets, and + * each request re-authorises and re-resolves the environment — so they go in one request. + */ + +/** One environment lookup and one repo lookup serve a whole batch. */ +export const MAX_URIS_PER_RESOLVE_REQUEST = 25; + +/** A transient failure is worth retrying; a third one isn't. */ +export const MAX_RESOLVE_ATTEMPTS = 3; + +export const RESOLVE_RETRY_DELAY_MS = 1_000; + +/** Deduplicates, then splits into requests no bigger than the cap. */ +export function planUriBatches( + uris: readonly string[], + cap: number = MAX_URIS_PER_RESOLVE_REQUEST +): string[][] { + const unique = [...new Set(uris)]; + const batches: string[][] = []; + for (let index = 0; index < unique.length; index += cap) { + batches.push(unique.slice(index, index + cap)); + } + return batches; +} diff --git a/apps/webapp/app/components/dashboard-agent/view-actions.test.ts b/apps/webapp/app/components/dashboard-agent/view-actions.test.ts new file mode 100644 index 00000000000..a9914da149a --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/view-actions.test.ts @@ -0,0 +1,67 @@ +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 new file mode 100644 index 00000000000..1d59d20b081 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/view-actions.ts @@ -0,0 +1,27 @@ +// 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 new file mode 100644 index 00000000000..9df716b9ed3 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/view-blocks.test.ts @@ -0,0 +1,119 @@ +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 new file mode 100644 index 00000000000..1b7379befce --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/view-blocks.ts @@ -0,0 +1,63 @@ +// 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 new file mode 100644 index 00000000000..8b36e0c3f3c --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/view-catalog.test.ts @@ -0,0 +1,107 @@ +/** + * 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 ab9fdbb1380..2e088770399 100644 --- a/apps/webapp/app/components/dashboard-agent/view-catalog.tsx +++ b/apps/webapp/app/components/dashboard-agent/view-catalog.tsx @@ -1,25 +1,63 @@ -import type { ViewBlock } from "@internal/dashboard-agent"; +import type { AgentIntent, ViewBlock } from "@internal/dashboard-agent-contracts"; +import { ActionsBlock } from "./ActionsBlock"; import { AgentChart } from "./AgentChart"; +import { InvestigationCard } from "./InvestigationCard"; +import { ReportView, type ResolvedUri } from "./ReportView"; import { RunDiagnosisCard } from "./RunDiagnosisCard"; +import { blockKey, latestRevisionEntries } from "./view-blocks"; -// 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[] }) { +// 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; +}) { if (!Array.isArray(blocks)) return null; return (
    - {blocks.map((block, i) => { + {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); switch (block.type) { case "diagnosis": - return ; + return ; case "chart": - return ; + return ; + case "actions": + return ; + // Revisions share the investigationId, so latest-wins keeps one card. + case "investigation": + return ( + + ); + case "report": + return ( + + ); default: return null; } diff --git a/apps/webapp/app/components/metrics/MiniLineChart.tsx b/apps/webapp/app/components/metrics/MiniLineChart.tsx index c36e91d0b90..f758ad3370d 100644 --- a/apps/webapp/app/components/metrics/MiniLineChart.tsx +++ b/apps/webapp/app/components/metrics/MiniLineChart.tsx @@ -42,6 +42,8 @@ export type MiniLineChartProps = { * throttled magnitude carried by the tooltip. */ throttled?: number[]; + /** Tooltip wording for the overlay buckets. Null omits the overlay line. */ + overlayLabel?: string | null; /** Epoch ms of the first bucket's start. When omitted, the last bucket is anchored to now. */ bucketStartMs?: number; /** Width of each bucket in ms. Defaults to one hour. */ @@ -76,6 +78,7 @@ export type MiniLineChartProps = { export function MiniLineChart({ data, throttled, + overlayLabel = "throttled", bucketStartMs, bucketIntervalMs, color = "var(--color-tasks)", @@ -128,7 +131,7 @@ export function MiniLineChart({ } + content={} allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 1000 }} animationDuration={0} @@ -195,7 +198,8 @@ function MiniLineChartTooltip({ active, payload, unitLabel, -}: TooltipProps & { unitLabel: UnitLabel }) { + overlayLabel = "throttled", +}: TooltipProps & { unitLabel: UnitLabel; overlayLabel?: string | null }) { if (!active || !payload || payload.length === 0) return null; const entry = payload[0].payload as MiniLineChartDatum; const date = entry.date instanceof Date ? entry.date : new Date(entry.date); @@ -211,9 +215,9 @@ function MiniLineChartTooltip({ {entry.count === 1 ? unitLabel.singular : unitLabel.plural}
    - {throttled > 0 && ( + {throttled > 0 && overlayLabel !== null && (
    - {throttled.toLocaleString()} throttled + {throttled.toLocaleString()} {overlayLabel}
    )}
    diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 9a06c933d97..189a23da93f 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -242,8 +242,16 @@ export function SideMenuItem({ /** Button styled to match {@link SideMenuItem}, for entries that open a dialog rather than navigate. */ export const SideMenuItemButton = forwardRef< HTMLButtonElement, - { icon: RenderIcon; name: string; trailing?: ReactNode } & ButtonHTMLAttributes ->(function SideMenuItemButton({ icon, name, trailing, className, type, ...props }, ref) { + { + icon: RenderIcon; + name: string; + trailing?: ReactNode; + iconClassName?: string; + } & ButtonHTMLAttributes +>(function SideMenuItemButton( + { icon, name, trailing, className, iconClassName, type, ...props }, + ref +) { return (