diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx new file mode 100644 index 00000000000..bd45153d95d --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx @@ -0,0 +1,32 @@ +import type { AgentIntent, ChartAction } from "@internal/dashboard-agent-contracts"; +import { QueryResultsChart } from "~/components/code/QueryResultsChart"; +import { AGENT_CHART_PLOT_CLASS, ChartActions } from "../../AgentChart"; +import { AgentCard, AgentCardHeader } from "../../agent-card"; +import { demoChart } from "../fixtures/chart"; + +export function DemoChartCard({ + title = demoChart.title, + actions, + onIntent, +}: { + title?: string; + actions?: ChartAction[]; + onIntent?: (intent: AgentIntent) => void; +}) { + return ( + + {title ? ( + {title} + ) : null} +
+ +
+ +
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx new file mode 100644 index 00000000000..c2f1820750d --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx @@ -0,0 +1,60 @@ +import { + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + NoSymbolIcon, +} from "@heroicons/react/20/solid"; +import { Button } from "~/components/primitives/Buttons"; +import { cn } from "~/utils/cn"; +import { AgentStatusIcon } from "../../agent-badges"; +import { ChatStatusLine } from "../../chat-layout"; +import type { DemoIntent } from "../fixtures/intents"; + +export function DemoIntentBubble({ + intent, + onIntercept, +}: { + intent: DemoIntent; + onIntercept?: (message: string) => void; +}) { + const rejected = !intent.executable; + + return ( +
+ + } + > +

{intent.outcome}

+ {intent.deepLinkLabel ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts new file mode 100644 index 00000000000..3b40087e01f --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts @@ -0,0 +1,314 @@ +import { + agentIntentSchema, + agentPageContextSchema, + isRevisableBlock, + safeParseStoredViewBlock, + safeParseTriggerUri, + suggestedPromptSchema, + viewBlockSchema, + watchIdentity, + watchSpecSchema, + SUGGESTED_PROMPT_CAP, + type Evidence, +} from "@internal/dashboard-agent-contracts"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import * as fixtures from "./fixtures"; +import { DEMO_ID_PREFIX, DEMO_MARKER } from "./ids"; + +const DEMO_DIR = __dirname; + +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + return statSync(path).isDirectory() ? walk(path) : [path]; + }); +} + +const sourceFiles = walk(DEMO_DIR).filter( + (path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") +); + +function importSpecifiers(source: string): string[] { + return [...source.matchAll(/(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g)].map( + (match) => match[1]! + ); +} + +describe("demo ids", () => { + it("namespaces investigation, hypothesis, watch and prompt ids", () => { + for (const investigation of Object.values(fixtures.demoInvestigations)) { + expect(investigation.investigationId.startsWith(DEMO_ID_PREFIX)).toBe(true); + for (const hypothesis of investigation.hypotheses) { + expect(hypothesis.id.startsWith(DEMO_ID_PREFIX)).toBe(true); + } + } + for (const watch of fixtures.demoWatches.row) { + expect(watch.id.startsWith(DEMO_ID_PREFIX)).toBe(true); + } + for (const prompts of Object.values(fixtures.demoPromptSets)) { + for (const prompt of prompts) { + expect(prompt.id.startsWith(DEMO_ID_PREFIX)).toBe(true); + } + } + }); + + it("marks every resource id, so nothing can pass for a real one", () => { + for (const value of Object.values(fixtures.demoViewBlocks)) { + if (value.type === "diagnosis") { + expect(value.runId).toContain(DEMO_MARKER); + } + } + for (const id of Object.values({ + failedRunId: fixtures.demoInvestigationConcluded.runId, + slowRunId: fixtures.demoInvestigationInconclusive.runId, + })) { + expect(id).toContain(DEMO_MARKER); + } + }); +}); + +describe("view block fixtures", () => { + const blocks = Object.values(fixtures.demoViewBlocks); + + it("parses every block through the lenient stored-block schema", () => { + for (const block of blocks) { + const result = safeParseStoredViewBlock(block); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it("parses the enveloped blocks through the strict schema too", () => { + for (const block of [ + fixtures.demoDiagnosisBlockFirstPass, + fixtures.demoDiagnosisBlockRevised, + fixtures.demoChartBlock, + ]) { + expect(viewBlockSchema.safeParse(block).success).toBe(true); + expect(isRevisableBlock(block)).toBe(true); + } + }); + + it("keeps one legacy, envelope-less block that is not revisable", () => { + const legacy = fixtures.demoLegacyDiagnosisBlock; + expect(viewBlockSchema.safeParse(legacy).success).toBe(false); + expect(safeParseStoredViewBlock(legacy).success).toBe(true); + expect(isRevisableBlock(legacy)).toBe(false); + }); + + it("revises a block by id rather than emitting a second one", () => { + expect(fixtures.demoDiagnosisBlockRevised.id).toBe(fixtures.demoDiagnosisBlockFirstPass.id); + expect(fixtures.demoDiagnosisBlockRevised.revision).toBeGreaterThan( + fixtures.demoDiagnosisBlockFirstPass.revision + ); + }); +}); + +describe("investigation fixtures", () => { + const investigations = Object.values(fixtures.demoInvestigations); + + const allEvidence = (): Evidence[] => + investigations.flatMap((investigation) => [ + ...investigation.evidence, + ...investigation.hypotheses.flatMap((hypothesis) => hypothesis.evidence), + ]); + + it("cites only valid trigger:// URIs, with the kind matching the URI", () => { + for (const evidence of allEvidence()) { + const parsed = safeParseTriggerUri(evidence.uri); + expect(parsed.success, `${evidence.uri}: ${!parsed.success ? parsed.error : ""}`).toBe(true); + if (parsed.success) expect(parsed.data.kind).toBe(evidence.kind); + expect(evidence.uri).toContain(DEMO_MARKER); + } + }); + + it("only offers a fix when it concluded, and only 'check next' when it didn't", () => { + for (const investigation of investigations) { + if (investigation.outcome === "concluded") { + expect(investigation.remediation).toBeTruthy(); + expect(investigation.checkNext).toBeUndefined(); + } else { + expect(investigation.remediation).toBeUndefined(); + } + if (investigation.outcome === "inconclusive") { + expect(investigation.checkNext?.length).toBeGreaterThan(0); + } + } + }); + + it("gives the concluded card at least two settled hypotheses", () => { + const settled = fixtures.demoInvestigationConcluded.hypotheses.filter( + (hypothesis) => hypothesis.verdict !== "testing" + ); + expect(settled.length).toBeGreaterThanOrEqual(2); + expect(settled.some((h) => h.verdict === "validated")).toBe(true); + expect(settled.some((h) => h.verdict === "invalidated")).toBe(true); + expect( + fixtures.demoInvestigationConcluded.hypotheses.every( + (h) => h.verdict === "testing" || h.finding + ) + ).toBe(true); + }); + + it("keeps a streaming revision with a hypothesis still testing", () => { + expect(fixtures.demoInvestigationStreamingRev1.investigationId).toBe( + fixtures.demoInvestigationStreamingRev0.investigationId + ); + expect(fixtures.demoInvestigationStreamingRev1.revision).toBeGreaterThan( + fixtures.demoInvestigationStreamingRev0.revision + ); + expect( + fixtures.demoInvestigationStreamingRev1.hypotheses.some((h) => h.verdict === "testing") + ).toBe(true); + }); + + it("hedges the dirty-commit variant with the agreed wording", () => { + expect(fixtures.demoInvestigationDirtyCommit.caveat?.kind).toBe("dirty_commit"); + expect(fixtures.demoInvestigationDirtyCommit.caveat?.message).toContain( + "nearest repository snapshot" + ); + }); + + it("cites file:line@sha in the show-code turn", () => { + expect(fixtures.demoShowCodeMarkdown).toMatch(/\.ts:\d+(-\d+)?@[0-9a-z]{7}/); + expect(fixtures.demoShowCodeMarkdown).toContain("```diff"); + }); +}); + +describe("watch fixtures", () => { + it("validates every spec against the contracts schema", () => { + for (const watch of fixtures.demoWatches.row) { + const result = watchSpecSchema.safeParse(watch.spec); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it("derives the chip identity from the spec", () => { + for (const watch of fixtures.demoWatches.row) { + expect(watch.identity).toBe(watchIdentity(watch.spec)); + } + }); + + it("covers every watch status and offers cancel only while active", () => { + const statuses = new Set(fixtures.demoWatches.row.map((watch) => watch.status)); + expect(statuses).toEqual(new Set(["active", "fired", "expired", "cancelled"])); + for (const watch of fixtures.demoWatches.row) { + expect(watch.cancellable).toBe(watch.status === "active"); + } + }); + + it("has an expiry narration that admits it could not verify", () => { + expect(fixtures.demoWatchNarration.expiryUnverified).toContain("couldn't verify"); + }); +}); + +describe("intent fixtures", () => { + it("validates every intent and marks propose_fix non-executable", () => { + for (const demoIntent of Object.values(fixtures.demoIntents)) { + const result = agentIntentSchema.safeParse(demoIntent.intent); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(demoIntent.executable).toBe(demoIntent.intent.kind !== "propose_fix"); + } + }); + + it("points the filtered-runs example at the runs collection, not one run", () => { + const target = fixtures.demoIntents.navigateToFailedRuns.intent; + expect(target.kind).toBe("navigate"); + if (target.kind !== "navigate") return; + const parsed = safeParseTriggerUri(target.target); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.kind).toBe("runs"); + }); +}); + +describe("page context and prompt fixtures", () => { + it("validates every page context", () => { + for (const context of Object.values(fixtures.demoPageContexts)) { + const result = agentPageContextSchema.safeParse(context); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it("covers all four signal kinds", () => { + const kinds = new Set(fixtures.demoSignalsByPriority.map((signal) => signal.kind)); + expect(kinds).toEqual( + new Set(["fresh_failure", "waiting_run", "slow_run", "concurrency_saturation"]) + ); + }); + + it("validates every chip, stays under the cap, and promotes at most one", () => { + for (const prompts of Object.values(fixtures.demoPromptSets)) { + expect(prompts.length).toBeLessThanOrEqual(SUGGESTED_PROMPT_CAP); + expect(prompts.filter((prompt) => prompt.source === "promoted").length).toBeLessThanOrEqual( + 1 + ); + for (const prompt of prompts) { + expect(suggestedPromptSchema.safeParse(prompt).success).toBe(true); + } + } + }); + + it("drops dismissed chips from the resolved row", () => { + for (const id of fixtures.demoDismissedPromptIds) { + expect(fixtures.demoPromptsAfterDismissal.some((prompt) => prompt.id === id)).toBe(false); + } + }); +}); + +describe("report fixtures", () => { + it("covers a healthy and a degraded verdict", () => { + expect(fixtures.demoHealthyReport.summary.severity).toBe("ok"); + expect(fixtures.demoDegradedReport.summary.severity).toBe("crit"); + }); + + it("references only metrics the report carries, and only links it declares", () => { + for (const vm of Object.values(fixtures.demoReports)) { + const metricIds = new Set(vm.metrics.map((metric) => metric.id)); + for (const finding of vm.findings) { + for (const id of finding.metricIds) expect(metricIds.has(id), id).toBe(true); + } + const linkKeys = new Set(vm.links.map((link) => link.key)); + for (const entry of vm.footer) { + if (entry.link) expect(linkKeys.has(entry.link), entry.link).toBe(true); + } + expect(vm.footer.length).toBeLessThanOrEqual(3); + } + }); +}); + +describe("chart fixtures", () => { + it("has a row for every configured column", () => { + const columns = fixtures.demoChart.columns.map((column) => column.name); + for (const row of fixtures.demoChart.rows) { + expect(Object.keys(row).sort()).toEqual([...columns].sort()); + } + expect(columns).toContain(fixtures.demoChart.config.xAxisColumn); + for (const y of fixtures.demoChart.config.yAxisColumns) expect(columns).toContain(y); + }); +}); + +describe("isolation", () => { + it("imports no server module and no route", () => { + for (const path of sourceFiles) { + const specifiers = importSpecifiers(readFileSync(path, "utf8")); + for (const specifier of specifiers) { + expect(specifier.includes(".server"), `${path} -> ${specifier}`).toBe(false); + expect(/routes?\//.test(specifier), `${path} -> ${specifier}`).toBe(false); + expect(specifier.includes("~/db"), `${path} -> ${specifier}`).toBe(false); + } + } + }); + + it("makes no network calls", () => { + for (const path of sourceFiles) { + const source = readFileSync(path, "utf8"); + expect(/\bfetch\s*\(/.test(source), path).toBe(false); + expect(/\buseFetcher\b/.test(source), path).toBe(false); + } + }); + + it("has no server file of its own", () => { + expect(sourceFiles.filter((path) => path.endsWith(".server.ts"))).toEqual([]); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/blocks.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/blocks.ts new file mode 100644 index 00000000000..2b5c7c5db8f --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/blocks.ts @@ -0,0 +1,126 @@ +import { + VIEW_BLOCK_VERSION, + type EnvelopedChartBlock, + type EnvelopedDiagnosisBlock, + type ViewBlock, +} from "@internal/dashboard-agent-contracts"; +import { demoId, demoRunsUri, DEMO_WORLD } from "../ids"; + +const envelope = (id: string, revision = 0) => ({ + id: demoId(id), + revision, + version: VIEW_BLOCK_VERSION, +}); + +export const demoDiagnosisBlockFirstPass: EnvelopedDiagnosisBlock = { + ...envelope("diagnosis-order-receipt", 0), + type: "diagnosis", + runId: DEMO_WORLD.failedRunId, + summary: `${DEMO_WORLD.taskId} failed while calling the email provider. The call came back 429 and the run exhausted its 3 retries.`, + category: "rate_limit", + likelyCause: + "The email provider is rate limiting this API key. All three attempts landed inside the same 20-second window, so the retries never had a chance to clear the limit.", + confidence: "medium", + evidence: [ + { + type: "error", + detail: "ProviderError: 429 Too Many Requests (rate_limit_exceeded)", + reference: DEMO_WORLD.failedRunId, + }, + { + type: "failed_span", + detail: "sendEmail span failed after 412ms on attempt 3 of 3", + reference: DEMO_WORLD.failedSpanId, + }, + ], + nextSteps: [ + "Spread the retries out: raise the retry delay so attempts don't land in the same rate-limit window.", + "Cap concurrency on the queue so the task can't burst past the provider's per-second limit.", + ], +}; + +export const demoDiagnosisBlockRevised: EnvelopedDiagnosisBlock = { + ...demoDiagnosisBlockFirstPass, + ...envelope("diagnosis-order-receipt", 1), + summary: `${DEMO_WORLD.taskId} failed because the email provider rate limited it. 41 runs on this queue hit the same 429 in the last hour — this run isn't special.`, + confidence: "high", + impact: `41 runs of ${DEMO_WORLD.taskId} failed the same way in the last hour, all on the ${DEMO_WORLD.queue} queue.`, + evidence: [ + ...demoDiagnosisBlockFirstPass.evidence, + { + type: "historical_match", + detail: "41 runs failed with the same error fingerprint in the last hour", + reference: DEMO_WORLD.errorFingerprint, + }, + { + type: "source", + detail: "retry.maxAttempts is 3 with a 1s base delay and no jitter", + reference: `${DEMO_WORLD.sourcePath}:18`, + }, + ], + nextSteps: [ + "Raise the retry delay (or add jitter) so attempts don't all land inside one rate-limit window.", + `Cap concurrency on ${DEMO_WORLD.queue} to stay under the provider's per-second limit.`, + "Consider a queue-level rate limit so a backlog can't burst into the provider.", + ], + actions: [ + { label: "View run", kind: "view_run", target: DEMO_WORLD.failedRunId }, + { + label: "Read the retries docs", + kind: "docs", + target: "https://trigger.dev/docs/errors-retrying", + }, + ], +}; + +export const demoChartBlock: EnvelopedChartBlock = { + ...envelope("chart-failures-by-task", 0), + type: "chart", + title: "Failed runs per hour, by task", + query: + "SELECT toStartOfHour(created_at) AS hour, task_identifier, countIf(status = 'COMPLETED_WITH_ERROR') AS failures FROM task_runs GROUP BY hour, task_identifier ORDER BY hour", + period: "24h", + chartType: "line", + xAxisColumn: "hour", + yAxisColumns: ["failures"], + groupByColumn: "task_identifier", + stacked: false, + aggregation: "sum", + actions: [ + { + label: `Investigate ${DEMO_WORLD.taskId}`, + intent: { + kind: "ask", + prompt: `Investigate the ${DEMO_WORLD.taskId} failures — why are they failing?`, + }, + }, + { + label: "See its failed runs", + intent: { + kind: "navigate", + target: demoRunsUri(), + filters: { tasks: [DEMO_WORLD.taskId], statuses: ["COMPLETED_WITH_ERROR"], period: "1d" }, + }, + }, + ], +}; + +// No envelope on purpose: the pre-envelope transcript path must still render. +export const demoLegacyDiagnosisBlock: ViewBlock = { + type: "diagnosis", + runId: DEMO_WORLD.priorRunId, + summary: + "This run failed the same way three weeks ago, before the panel stamped identity onto its cards.", + category: "rate_limit", + likelyCause: "The email provider rate limited the same API key.", + confidence: "medium", + evidence: [{ type: "error", detail: "ProviderError: 429 Too Many Requests" }], + nextSteps: ["Nothing to do — kept as a fixture for the pre-envelope render path."], +}; + +export const demoViewBlocks = { + diagnosisFirstPass: demoDiagnosisBlockFirstPass, + diagnosisRevised: demoDiagnosisBlockRevised, + chart: demoChartBlock, + legacyDiagnosis: demoLegacyDiagnosisBlock, +} as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/chart.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/chart.ts new file mode 100644 index 00000000000..c1a00a2632a --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/chart.ts @@ -0,0 +1,50 @@ +import type { OutputColumnMetadata } from "@internal/clickhouse"; +import type { ChartConfiguration } from "~/components/metrics/QueryWidget"; + +export const demoChartColumns: OutputColumnMetadata[] = [ + { name: "hour", type: "DateTime" }, + { name: "task_identifier", type: "String" }, + { name: "failures", type: "UInt64", format: "quantity" }, +]; + +const SERIES: Record = { + "send-order-receipt": [1, 0, 2, 1, 3, 2, 4, 9, 14, 22, 31, 41], + "generate-monthly-report": [0, 1, 0, 0, 1, 0, 2, 1, 0, 1, 2, 1], + "sync-crm-contacts": [3, 2, 4, 3, 2, 3, 2, 4, 3, 2, 3, 2], +}; + +const START_MS = Date.parse("2026-07-26T23:00:00.000Z"); +const HOUR_MS = 3_600_000; + +export const demoChartRows: Record[] = Object.entries(SERIES).flatMap( + ([task, points]) => + points.map((failures, i) => ({ + hour: new Date(START_MS + i * HOUR_MS).toISOString(), + task_identifier: task, + failures, + })) +); + +export const demoChartConfig: ChartConfiguration = { + chartType: "line", + xAxisColumn: "hour", + yAxisColumns: ["failures"], + groupByColumn: "task_identifier", + stacked: false, + sortByColumn: null, + sortDirection: "desc", + aggregation: "sum", +}; + +export const demoChartTimeRange = { + from: new Date(START_MS).toISOString(), + to: new Date(START_MS + 11 * HOUR_MS).toISOString(), +}; + +export const demoChart = { + rows: demoChartRows, + columns: demoChartColumns, + config: demoChartConfig, + timeRange: demoChartTimeRange, + title: "Failed runs per hour, by task", +} as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts new file mode 100644 index 00000000000..15a2f1be72a --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts @@ -0,0 +1,8 @@ +export * from "./blocks"; +export * from "./chart"; +export * from "./intents"; +export * from "./investigation"; +export * from "./messages"; +export * from "./page-context"; +export * from "./reports"; +export * from "./watches"; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts new file mode 100644 index 00000000000..3fea9f20950 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts @@ -0,0 +1,60 @@ +import { isExecutableIntent, type AgentIntent } from "@internal/dashboard-agent-contracts"; +import { DEMO_WORLD, demoRunUri, demoRunsUri } from "../ids"; +import { demoBacklogDrainWatch } from "./watches"; + +export type DemoIntent = { + intent: AgentIntent; + outcome: string; + deepLinkLabel?: string; + executable: boolean; +}; + +const demoIntent = (intent: AgentIntent, outcome: string, deepLinkLabel?: string): DemoIntent => ({ + intent, + outcome, + deepLinkLabel, + executable: isExecutableIntent(intent), +}); + +export const demoNavigateToFailedRuns = demoIntent( + { + kind: "navigate", + target: demoRunsUri(), + filters: { + statuses: ["COMPLETED_WITH_ERROR"], + period: "24h", + tasks: [DEMO_WORLD.taskId], + }, + }, + "Opened runs filtered to failed · last 24h · send-order-receipt", + "/runs?statuses=COMPLETED_WITH_ERROR&period=24h&tasks=send-order-receipt" +); + +export const demoNavigateToRun = demoIntent( + { kind: "navigate", target: demoRunUri(DEMO_WORLD.failedRunId) }, + `Opened ${DEMO_WORLD.failedRunId}`, + `/runs/${DEMO_WORLD.failedRunId}` +); + +export const demoAskIntent = demoIntent( + { kind: "ask", prompt: "Do you want me to watch the retry and tell you when it finishes?" }, + "Asked a follow-up" +); + +export const demoWatchIntent = demoIntent( + { kind: "watch", spec: demoBacklogDrainWatch.spec }, + `Watching ${DEMO_WORLD.backlogQueue} · checking every 5 min for up to 6h` +); + +export const demoProposeFixIntent = demoIntent( + { kind: "propose_fix", investigationId: "demo:investigation-order-receipt" }, + "Rejected: proposing a fix isn't available yet" +); + +export const demoIntents = { + navigateToFailedRuns: demoNavigateToFailedRuns, + navigateToRun: demoNavigateToRun, + ask: demoAskIntent, + watch: demoWatchIntent, + proposeFix: demoProposeFixIntent, +} as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/investigation.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/investigation.ts new file mode 100644 index 00000000000..8b3c250633b --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/investigation.ts @@ -0,0 +1,438 @@ +// Block `id` is the `investigationId` and `revision` climbs. The contracts package freezes that. +import type { Evidence } from "@internal/dashboard-agent-contracts"; +import { + DEMO_WORLD, + demoDeploymentUri, + demoErrorUri, + demoId, + demoQueueUri, + demoRunUri, + demoSourceUri, + demoSpanUri, +} from "../ids"; + +export type DemoHypothesisVerdict = "testing" | "validated" | "invalidated"; + +export type DemoHypothesis = { + id: string; + statement: string; + verdict: DemoHypothesisVerdict; + finding?: string; + evidence: Evidence[]; +}; + +export type DemoInvestigationOutcome = "in_progress" | "concluded" | "inconclusive"; + +export type DemoInvestigationSeverity = "info" | "warn" | "crit"; + +export type DemoInvestigationCaveat = { + kind: "dirty_commit"; + message: string; +}; + +export type DemoInvestigation = { + investigationId: string; + revision: number; + outcome: DemoInvestigationOutcome; + severity: DemoInvestigationSeverity; + confidence: "high" | "medium" | "low"; + runId?: string; + title: string; + headline: string; + remediation?: string; + checkNext?: string[]; + progress?: string; + hypotheses: DemoHypothesis[]; + evidence: Evidence[]; + caveat?: DemoInvestigationCaveat; + startedAt: string; + updatedAt: string; +}; + +const runUri = demoRunUri(DEMO_WORLD.failedRunId); +const spanUri = demoSpanUri(DEMO_WORLD.failedRunId, DEMO_WORLD.failedSpanId); +const errorUri = demoErrorUri(DEMO_WORLD.errorFingerprint); +const queueUri = demoQueueUri(DEMO_WORLD.queue); +const sourceUri = demoSourceUri(DEMO_WORLD.sourceSha, DEMO_WORLD.sourcePath, 18); + +const INVESTIGATION_ID = demoId("investigation-order-receipt"); + +const errorEvidence: Evidence = { + kind: "error", + uri: errorUri, + label: "rate_limit_exceeded · 41 runs in the last hour", + excerpt: "ProviderError: 429 Too Many Requests (rate_limit_exceeded)", +}; + +const spanEvidence: Evidence = { + kind: "span", + uri: spanUri, + label: "sendEmail span, attempt 3 of 3", + excerpt: "sendEmail 412ms ✕ 429 Too Many Requests", +}; + +const sourceEvidence: Evidence = { + kind: "source", + uri: sourceUri, + label: `${DEMO_WORLD.sourcePath}:18`, + excerpt: "retry: { maxAttempts: 3, minTimeoutInMs: 1_000, factor: 1 },", +}; + +const queueEvidence: Evidence = { + kind: "queue", + uri: queueUri, + label: `${DEMO_WORLD.queue} · concurrency 50 of 50`, + excerpt: "concurrency pinned at 50 for 38 of the last 60 min", +}; + +const runEvidence: Evidence = { + kind: "run", + uri: runUri, + label: `${DEMO_WORLD.failedRunId} · failed after 3 attempts`, + excerpt: "attempt 1 429 · attempt 2 429 · attempt 3 429 — all within 19.4s", +}; + +const priorRunEvidence: Evidence = { + kind: "run", + uri: demoRunUri(DEMO_WORLD.priorRunId), + label: `${DEMO_WORLD.priorRunId} · same payload, completed in 1.2s`, + excerpt: "2,104 runs with this payload shape succeeded earlier today", +}; + +const deploymentEvidence: Evidence = { + kind: "deployment", + uri: demoDeploymentUri(DEMO_WORLD.deploymentVersion), + label: `${DEMO_WORLD.deploymentVersion} · deployed 19h before the first failure`, + excerpt: "first failure 09:02, deploy 14:11 the previous day — no overlap", +}; + +export const demoInvestigationStreamingRev0: DemoInvestigation = { + investigationId: INVESTIGATION_ID, + revision: 0, + outcome: "in_progress", + severity: "warn", + confidence: "low", + runId: DEMO_WORLD.failedRunId, + title: `Why is ${DEMO_WORLD.taskId} failing?`, + headline: + "All three attempts of this run ended in an error from the email provider. I'm reading the spans to see which call failed and whether the retries had a chance to succeed.", + progress: "Reading the run's spans", + hypotheses: [ + { + id: demoId("hyp-rate-limit"), + statement: "The email provider is rate limiting this API key.", + verdict: "testing", + evidence: [], + }, + { + id: demoId("hyp-bad-payload"), + statement: "The payload is malformed and the provider rejects it.", + verdict: "testing", + evidence: [], + }, + { + id: demoId("hyp-retry-window"), + statement: "The retry schedule keeps every attempt inside one rate-limit window.", + verdict: "testing", + evidence: [], + }, + ], + evidence: [runEvidence, spanEvidence], + startedAt: "2026-07-27T10:14:02.000Z", + updatedAt: "2026-07-27T10:14:06.000Z", +}; + +export const demoInvestigationEarly: DemoInvestigation = { + investigationId: demoId("investigation-order-receipt-early"), + revision: 0, + outcome: "in_progress", + severity: "warn", + confidence: "low", + runId: DEMO_WORLD.failedRunId, + title: `Why is ${DEMO_WORLD.taskId} failing?`, + headline: + "The run failed after three attempts. I'm reading its spans to see which call failed before I put any hypotheses up.", + progress: "Reading the run's spans", + hypotheses: [], + evidence: [runEvidence], + startedAt: "2026-07-27T10:14:02.000Z", + updatedAt: "2026-07-27T10:14:03.000Z", +}; + +export const demoInvestigationStreamingRev1: DemoInvestigation = { + ...demoInvestigationStreamingRev0, + revision: 1, + confidence: "medium", + headline: + "Every attempt came back 429 rate_limit_exceeded, and 41 other runs of this task hit the same error in the last hour. Checking whether the retry schedule made it worse.", + progress: "Comparing against the last hour of runs on this queue", + hypotheses: [ + { + ...demoInvestigationStreamingRev0.hypotheses[0]!, + verdict: "validated", + finding: "All three attempts returned 429 rate_limit_exceeded inside a 20-second window.", + evidence: [errorEvidence, spanEvidence], + }, + { + ...demoInvestigationStreamingRev0.hypotheses[1]!, + verdict: "invalidated", + finding: + "The same payload shape succeeded on 2,104 runs earlier today, and the provider never returned a 4xx other than 429.", + evidence: [priorRunEvidence], + }, + { + ...demoInvestigationStreamingRev0.hypotheses[2]!, + verdict: "testing", + evidence: [queueEvidence], + }, + ], + evidence: [runEvidence, errorEvidence, queueEvidence], + updatedAt: "2026-07-27T10:14:11.000Z", +}; + +export const demoInvestigationConcluded: DemoInvestigation = { + investigationId: INVESTIGATION_ID, + revision: 2, + outcome: "concluded", + severity: "crit", + confidence: "high", + runId: DEMO_WORLD.failedRunId, + title: `${DEMO_WORLD.taskId} is failing on every retry`, + headline: + "The email provider is rate limiting this API key, and the task's retries all land inside the same limit window — so every attempt fails. 41 runs failed this way in the last hour.", + remediation: + "Spread the attempts out and stop the queue bursting into the provider: raise `minTimeoutInMs` to 30s with a factor of 2 (or add jitter) so the three attempts span the limit window instead of sharing it, and cap the queue's concurrency at 20 to stay under the provider's per-second ceiling. Neither change needs a code deploy if you set the queue limit from the dashboard.", + hypotheses: [ + { + id: demoId("hyp-rate-limit"), + statement: "The email provider is rate limiting this API key.", + verdict: "validated", + finding: + "All three attempts returned 429 rate_limit_exceeded, and 41 other runs hit the same fingerprint in the last hour.", + evidence: [errorEvidence, spanEvidence], + }, + { + id: demoId("hyp-bad-payload"), + statement: "The payload is malformed and the provider rejects it.", + verdict: "invalidated", + finding: + "The same payload succeeded on 2,104 runs earlier today; the provider never returned a 4xx other than 429.", + evidence: [priorRunEvidence, runEvidence], + }, + { + id: demoId("hyp-retry-window"), + statement: "The retry schedule keeps every attempt inside one rate-limit window.", + verdict: "validated", + finding: + "maxAttempts 3 with a 1s base delay and factor 1 puts all three attempts inside 20 seconds.", + evidence: [sourceEvidence, spanEvidence], + }, + { + id: demoId("hyp-queue-burst"), + statement: "The queue is bursting into the provider faster than its per-second ceiling.", + verdict: "validated", + finding: + "The queue sat at its concurrency limit of 50 for 38 of the last 60 minutes, so ~50 sends land on the provider at once every time it drains.", + evidence: [queueEvidence], + }, + { + id: demoId("hyp-deploy-regression"), + statement: "Yesterday's deploy introduced the failure.", + verdict: "invalidated", + finding: + "The deploy went out 19 hours before the first failure and the task ran clean for most of that window, so the timing rules it out.", + evidence: [deploymentEvidence], + }, + ], + evidence: [errorEvidence, spanEvidence, sourceEvidence, queueEvidence, deploymentEvidence], + startedAt: "2026-07-27T10:14:02.000Z", + updatedAt: "2026-07-27T10:14:24.000Z", +}; + +export const demoInvestigationConcludedNoCode: DemoInvestigation = { + investigationId: demoId("investigation-queue-saturation"), + revision: 2, + outcome: "concluded", + severity: "crit", + confidence: "high", + title: `${DEMO_WORLD.queue} is starving — nothing is starting`, + headline: `The ${DEMO_WORLD.queue} queue has sat at its concurrency limit of 50 for 38 of the last 60 minutes, so new runs wait behind the ones already running. The p95 wait is 2 minutes against a p50 of 38 seconds, and every run that does start finishes normally.`, + remediation: + "Raise the queue's concurrency limit (or the environment's, if that's the one it's hitting) until the depth trend flattens. You can set it from the queue page — no deploy needed. If the limit is deliberate, the backlog is telling you the arrival rate now exceeds it, and the trigger side is what has to change.", + hypotheses: [ + { + id: demoId("hyp-queue-limit"), + statement: "Runs are waiting on the queue's concurrency limit, not failing.", + verdict: "validated", + finding: + "The queue was pinned at 50 of 50 for 38 of the last 60 minutes while the depth climbed from 10 to 4,210, and no run in the window failed.", + evidence: [queueEvidence], + }, + { + id: demoId("hyp-queue-slow-task"), + statement: "The task itself got slower, so each slot is held longer.", + verdict: "invalidated", + finding: + "Runs that did start completed in ~1.2s, the same as earlier today — the slots turn over as fast as they ever did.", + evidence: [priorRunEvidence], + }, + ], + evidence: [queueEvidence, priorRunEvidence], + startedAt: "2026-07-27T11:02:00.000Z", + updatedAt: "2026-07-27T11:02:19.000Z", +}; + +export const demoInvestigationInconclusive: DemoInvestigation = { + investigationId: demoId("investigation-monthly-report"), + revision: 1, + outcome: "inconclusive", + severity: "warn", + confidence: "low", + runId: DEMO_WORLD.slowRunId, + title: `Why is ${DEMO_WORLD.slowTaskId} slow?`, + headline: + "This run has been executing for 24 minutes against a p95 of 3 minutes, and the time is spent inside one un-instrumented span. I can see where it stalls but not why — nothing in the telemetry explains it.", + checkNext: [ + "Add a span (or a log) around the report aggregation step so the stall shows up in the trace.", + "Check the warehouse the aggregation reads from — a slow upstream query would look exactly like this.", + "Compare against the last run that finished normally to see whether the payload got bigger.", + ], + hypotheses: [ + { + id: demoId("hyp-slow-oom"), + statement: "The run is thrashing against its memory limit.", + verdict: "invalidated", + finding: + "Peak memory stayed at 38% of the machine's limit for the whole run, and there is no OOM signal on the attempt.", + evidence: [ + { + kind: "run", + uri: demoRunUri(DEMO_WORLD.slowRunId), + label: `${DEMO_WORLD.slowRunId} · machine metrics, large-1x`, + excerpt: "memory peak 38% · cpu 11% avg · no restarts", + }, + ], + }, + { + id: demoId("hyp-slow-queue-wait"), + statement: "The run spent the time waiting for a worker rather than executing.", + verdict: "invalidated", + finding: + "It was dequeued 40ms after it was triggered and has been executing ever since — the time is inside the attempt, not in front of it.", + evidence: [ + { + kind: "queue", + uri: demoQueueUri(DEMO_WORLD.backlogQueue), + label: `${DEMO_WORLD.backlogQueue} · 3 of 20 concurrency in use`, + excerpt: "dequeued 40ms after trigger · no queue wait", + }, + ], + }, + { + id: demoId("hyp-slow-upstream"), + statement: "An upstream call inside the aggregation step is blocking.", + verdict: "testing", + finding: undefined, + evidence: [ + { + kind: "span", + uri: demoSpanUri(DEMO_WORLD.slowRunId, "span_demoe71f"), + label: "aggregate span · 23m 41s, no children", + excerpt: "aggregate 23m41s ● (no child spans)", + }, + ], + }, + ], + evidence: [ + { + kind: "run", + uri: demoRunUri(DEMO_WORLD.slowRunId), + label: `${DEMO_WORLD.slowRunId} · executing for 24m, p95 is 3m`, + excerpt: "status EXECUTING · attempt 1 · started 09:17:22", + }, + { + kind: "span", + uri: demoSpanUri(DEMO_WORLD.slowRunId, "span_demoe71f"), + label: "aggregate span · 23m 41s, no children", + excerpt: "aggregate 23m41s ● (no child spans)", + }, + ], + startedAt: "2026-07-27T09:41:00.000Z", + updatedAt: "2026-07-27T09:41:38.000Z", +}; + +export const demoInvestigationDegraded: DemoInvestigation = { + investigationId: demoId("investigation-order-receipt-degraded"), + revision: 1, + outcome: "inconclusive", + severity: "warn", + confidence: "low", + runId: DEMO_WORLD.failedRunId, + title: `Why is ${DEMO_WORLD.taskId} failing?`, + headline: `Every attempt of this run ended in a 429 from the email provider, and 41 other runs hit the same error in the last hour. I couldn't read the trace — the spans for this run are no longer retained — so I can't tell whether the retries all landed inside one rate-limit window, which is what would explain it.`, + checkNext: [ + "Re-run the investigation on a fresher failure, while its spans are still retained.", + "Check the provider's dashboard for the rate limit on this API key and when it resets.", + "Compare the task's retry settings against that window — three attempts inside one window would fail as a group.", + ], + hypotheses: [ + { + id: demoId("hyp-rate-limit"), + statement: "The email provider is rate limiting this API key.", + verdict: "validated", + finding: "All three attempts returned 429 rate_limit_exceeded on the same fingerprint.", + evidence: [errorEvidence], + }, + { + id: demoId("hyp-retry-window"), + statement: "The retry schedule keeps every attempt inside one rate-limit window.", + verdict: "testing", + finding: "The run's spans are no longer retained, so the attempt timings can't be read.", + evidence: [], + }, + ], + evidence: [runEvidence, errorEvidence], + startedAt: "2026-07-27T10:31:00.000Z", + updatedAt: "2026-07-27T10:31:14.000Z", +}; + +export const demoInvestigationDirtyCommit: DemoInvestigation = { + ...demoInvestigationConcluded, + investigationId: demoId("investigation-order-receipt-dirty"), + confidence: "medium", + caveat: { + kind: "dirty_commit", + message: + "Source lines below come from the nearest repository snapshot, not the exact deployed code — this deploy was built from a working tree with uncommitted changes. The run, span and error evidence is unaffected.", + }, +}; + +export const demoInvestigations = { + early: demoInvestigationEarly, + streamingRev0: demoInvestigationStreamingRev0, + streamingRev1: demoInvestigationStreamingRev1, + concluded: demoInvestigationConcluded, + concludedNoCode: demoInvestigationConcludedNoCode, + inconclusive: demoInvestigationInconclusive, + degraded: demoInvestigationDegraded, + dirtyCommit: demoInvestigationDirtyCommit, +} as const; + +export const demoShowCodeMarkdown = `Here's the change, against \`${DEMO_WORLD.sourcePath}:14-20@${DEMO_WORLD.sourceSha.slice(0, 7)}\`: + +\`\`\`diff +--- a/${DEMO_WORLD.sourcePath} ++++ b/${DEMO_WORLD.sourcePath} +@@ -14,7 +14,8 @@ export const sendOrderReceipt = task({ + id: "${DEMO_WORLD.taskId}", + retry: { + maxAttempts: 3, +- minTimeoutInMs: 1_000, +- factor: 1, ++ minTimeoutInMs: 30_000, ++ factor: 2, ++ randomize: true, + }, +\`\`\` + +That spreads the three attempts across ~2 minutes instead of 20 seconds. I haven't applied anything — this is the patch I'd suggest.`; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/messages.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/messages.ts new file mode 100644 index 00000000000..8cfc119170a --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/messages.ts @@ -0,0 +1,71 @@ +import type { UIMessage } from "@ai-sdk/react"; +import type { ViewBlock } from "@internal/dashboard-agent-contracts"; +import { demoId } from "../ids"; + +type Part = UIMessage["parts"][number]; + +export function demoMessageId(name: string): string { + return demoId(`msg-${name}`); +} + +export function userMessage(name: string, text: string): UIMessage { + return { id: demoMessageId(name), role: "user", parts: [{ type: "text", text }] }; +} + +export function assistantMessage(name: string, parts: Part[]): UIMessage { + return { id: demoMessageId(name), role: "assistant", parts }; +} + +export function textPart(text: string): Part { + return { type: "text", text, state: "done" }; +} + +export function streamingTextPart(text: string): Part { + return { type: "text", text, state: "streaming" }; +} + +export function reasoningPart(text: string): Part { + return { type: "reasoning", text, state: "done" }; +} + +export function toolPart(name: string, input: unknown, output: unknown, callName?: string): Part { + return { + type: `tool-${name}`, + toolCallId: demoId(`call-${callName ?? name}`), + state: "output-available", + input, + output, + } as Part; +} + +export function pendingToolPart(name: string, input: unknown, callName?: string): Part { + return { + type: `tool-${name}`, + toolCallId: demoId(`call-${callName ?? name}`), + state: "input-available", + input, + } as Part; +} + +export function failedToolPart( + name: string, + input: unknown, + errorText: string, + callName?: string +): Part { + return { + type: `tool-${name}`, + toolCallId: demoId(`call-${callName ?? name}`), + state: "output-error", + input, + errorText, + } as Part; +} + +export function renderViewPart(blocks: ViewBlock[], callName?: string): Part { + return toolPart("render_view", { blocks }, { blocks }, callName ?? "render-view"); +} + +export function sourceUrlPart(url: string, title: string): Part { + return { type: "source-url", sourceId: demoId(`source-${title}`), url, title } as Part; +} diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts new file mode 100644 index 00000000000..40ff3e148a7 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts @@ -0,0 +1,148 @@ +import { + watchIdentity, + type WatchSpec, + type WatchStatus, +} from "@internal/dashboard-agent-contracts"; +import { DEMO_WORLD, demoId } from "../ids"; + +export type DemoWatch = { + id: string; + spec: WatchSpec; + status: WatchStatus; + identity: string; + chipLabel: string; + createdAt: string; + expiresAt: string; + cancellable: boolean; +}; + +const watch = ( + name: string, + spec: WatchSpec, + chipLabel: string, + status: WatchStatus, + createdAt: string, + expiresAt: string +): DemoWatch => ({ + id: demoId(`watch-${name}`), + spec, + status, + identity: watchIdentity(spec), + chipLabel, + createdAt, + expiresAt, + cancellable: status === "active", +}); + +export const demoRunFinishedWatch = watch( + "run-finished", + { + kind: "run_finished", + runId: DEMO_WORLD.failedRunId, + note: "Tell me when the retry of send-order-receipt finishes.", + maxHours: 2, + checkEveryMinutes: 1, + }, + DEMO_WORLD.taskId, + "active", + "2026-07-27T10:15:10.000Z", + "2026-07-27T12:15:10.000Z" +); + +export const demoBacklogDrainWatch = watch( + "backlog-drain", + { + kind: "backlog_drain", + queue: DEMO_WORLD.backlogQueue, + note: "Tell me when the backlog on demo-backlog-drain clears.", + maxHours: 6, + checkEveryMinutes: 5, + }, + "backlog-drain", + "active", + "2026-07-27T09:02:00.000Z", + "2026-07-27T15:02:00.000Z" +); + +export const demoErrorRecurrenceWatch = watch( + "email-sends", + { + kind: "error_recurrence", + fingerprint: DEMO_WORLD.errorFingerprint, + note: "Tell me if the rate-limit error comes back.", + maxHours: 12, + checkEveryMinutes: 15, + }, + "email-sends", + "fired", + "2026-07-26T22:40:00.000Z", + "2026-07-27T10:40:00.000Z" +); + +export const demoHealthRecoveryWatch = watch( + "health-recovery", + { + kind: "health_recovery", + report: "health", + fromSeverity: "crit", + note: "Tell me when prod is healthy again.", + maxHours: 4, + checkEveryMinutes: 15, + }, + "health-recovery", + "expired", + "2026-07-27T04:20:00.000Z", + "2026-07-27T08:20:00.000Z" +); + +export const demoCancelledWatch = watch( + "run-start", + { + kind: "run_start", + runId: DEMO_WORLD.waitingRunId, + note: "Tell me when this run starts.", + maxHours: 1, + checkEveryMinutes: 1, + }, + "run-start", + "cancelled", + "2026-07-27T10:01:00.000Z", + "2026-07-27T11:01:00.000Z" +); + +export const demoWatchRow: DemoWatch[] = [ + demoRunFinishedWatch, + demoBacklogDrainWatch, + demoErrorRecurrenceWatch, + demoHealthRecoveryWatch, + demoCancelledWatch, +]; + +export const demoActiveWatchRow: DemoWatch[] = [demoRunFinishedWatch, demoBacklogDrainWatch]; + +export const demoWatchNarration = { + wake: `**The retry finished.** \`${DEMO_WORLD.failedRunId}\` completed successfully 4 minutes ago, on attempt 2 — the provider accepted the request once the delay pushed it out of the rate-limit window. + +I've stopped watching it. The other 40 runs from the same burst are still queued behind the concurrency limit; ask me if you want them watched too.`, + + expiry: `**I've stopped watching \`${DEMO_WORLD.backlogQueue}\`.** The 6-hour window is up and the backlog never fully drained — it's down from 4,812 to 610 pending, so it's clearing, just slower than the window I was given. + +Ask again if you want another 6 hours.`, + + expiryUnverified: `**I've stopped watching prod's health, but I couldn't verify the condition at expiry.** The health data was unavailable on my last few checks, so I can't tell you whether prod recovered — only that I never saw it recover. + +Re-run the health report to get a current answer.`, + + cancelled: `Stopped watching \`${DEMO_WORLD.waitingRunId}\`.`, +} as const; + +export const demoWatches = { + runFinished: demoRunFinishedWatch, + backlogDrain: demoBacklogDrainWatch, + errorRecurrence: demoErrorRecurrenceWatch, + healthRecovery: demoHealthRecoveryWatch, + cancelled: demoCancelledWatch, + row: demoWatchRow, + activeRow: demoActiveWatchRow, + narration: demoWatchNarration, +} as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/ids.ts b/apps/webapp/app/components/dashboard-agent/demo/ids.ts index 22697e3361b..ea41e3b30ad 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/ids.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/ids.ts @@ -1,10 +1,60 @@ -// Every id the demo layer produces contains "demo". +// Every id the demo layer produces contains "demo". Resource ids carry the marker +// inline: `trigger://` segments are percent-encoded, so `demo:` would render as `demo%3A`. +import { formatTriggerUri, type TriggerUri } from "@internal/dashboard-agent-contracts"; + export const DEMO_ID_PREFIX = "demo:"; +export const DEMO_MARKER = "demo"; + export function demoId(rest: string): string { return `${DEMO_ID_PREFIX}${rest}`; } +export function isDemoChatId(id: string | null | undefined): boolean { + return typeof id === "string" && id.startsWith(DEMO_ID_PREFIX); +} + +export const DEMO_PROJECT_REF = "proj_demo00000000000000"; +export const DEMO_ENVIRONMENT_ID = "env_demo00000000000000"; + +const scope = { projectRef: DEMO_PROJECT_REF, environmentId: DEMO_ENVIRONMENT_ID }; + +export function demoRunsUri(): TriggerUri { + return formatTriggerUri({ kind: "runs", ...scope }); +} + +export function demoRunUri(runId: string): TriggerUri { + return formatTriggerUri({ kind: "run", ...scope, runId }); +} + +export function demoSpanUri(runId: string, spanId: string): TriggerUri { + return formatTriggerUri({ kind: "span", ...scope, runId, spanId }); +} + +export function demoErrorUri(fingerprint: string): TriggerUri { + return formatTriggerUri({ kind: "error", ...scope, fingerprint }); +} + +export function demoQueueUri(name: string): TriggerUri { + return formatTriggerUri({ kind: "queue", ...scope, name }); +} + +export function demoDeploymentUri(version: string): TriggerUri { + return formatTriggerUri({ kind: "deployment", ...scope, version }); +} + +export function demoReportUri(key: string): TriggerUri { + return formatTriggerUri({ kind: "report", ...scope, key }); +} + +export function demoSourceUri(sha: string, path: string, line?: number): TriggerUri { + return formatTriggerUri({ kind: "source", ...scope, sha, path, ...(line ? { line } : {}) }); +} + +export function demoInvestigationUri(investigationId: string): TriggerUri { + return formatTriggerUri({ kind: "investigation", ...scope, investigationId }); +} + export const DEMO_WORLD = { failedRunId: "run_demo0f2c91", failedSpanId: "span_demoa41b", diff --git a/apps/webapp/app/components/dashboard-agent/demo/index.ts b/apps/webapp/app/components/dashboard-agent/demo/index.ts new file mode 100644 index 00000000000..161b83333aa --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/index.ts @@ -0,0 +1,7 @@ +// Must stay free of server imports. `demo.test.ts` asserts that. +export { DEMO_ID_PREFIX, DEMO_MARKER, DEMO_WORLD, demoId, demoReportUri } from "./ids"; + +export * as demoFixtures from "./fixtures"; + +export { DemoChartCard } from "./components/DemoChartCard"; +export { DemoIntentBubble } from "./components/DemoIntentBubble"; diff --git a/apps/webapp/app/routes/storybook.agent-investigation/route.tsx b/apps/webapp/app/routes/storybook.agent-investigation/route.tsx new file mode 100644 index 00000000000..518f8740ad7 --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-investigation/route.tsx @@ -0,0 +1,87 @@ +import { + INVESTIGATION_CAPABILITIES_VERSION, + type InvestigationCapabilities, +} from "@internal/dashboard-agent-contracts"; +import { demoFixtures } from "~/components/dashboard-agent/demo"; +import { InvestigationCard } from "~/components/dashboard-agent/InvestigationCard"; +import { investigationBlock } from "../storybook.agent-ui/fixtures"; +import { fixtureResolveUri, GalleryPage, noop } from "../storybook.agent-ui/gallery"; + +const { demoInvestigations } = demoFixtures; + +const citedUri = ( + fixture: (typeof demoInvestigations)[keyof typeof demoInvestigations], + kind: string +) => fixture.evidence.find((evidence) => evidence.kind === kind)!.uri; + +const codeGroundedCapabilities: InvestigationCapabilities = { + version: INVESTIGATION_CAPABILITIES_VERSION, + actions: [ + { + kind: "show_code", + label: "Show code", + intent: { + kind: "ask", + prompt: + "Show me the code behind this and propose the minimal fix as a fenced diff, anchored to the file, line and commit you read.", + }, + }, + { + kind: "view_similar", + label: "View similar failures", + intent: { kind: "navigate", target: citedUri(demoInvestigations.concluded, "error") }, + }, + ], +}; + +const notCodeGroundedCapabilities: InvestigationCapabilities = { + version: INVESTIGATION_CAPABILITIES_VERSION, + actions: [ + { + kind: "view_similar", + label: "View the queue", + intent: { kind: "navigate", target: citedUri(demoInvestigations.concludedNoCode, "queue") }, + }, + ], +}; + +const STATES: Record = { + "investigation-card-streaming-rev1": ( + + ), + "investigation-card-concluded": ( + + ), + "investigation-card-concluded-code-grounded": ( + + ), + "investigation-card-concluded-not-code-grounded": ( + + ), + "investigation-card-inconclusive": ( + + ), + "investigation-card-degraded": ( + + ), +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.agent-report/route.tsx b/apps/webapp/app/routes/storybook.agent-report/route.tsx new file mode 100644 index 00000000000..db85afffd4a --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-report/route.tsx @@ -0,0 +1,72 @@ +import type { ReportViewModelPayload } from "@internal/dashboard-agent-contracts"; +import { DEMO_WORLD, demoFixtures, demoReportUri } from "~/components/dashboard-agent/demo"; +import { ReportView } from "~/components/dashboard-agent/ReportView"; +import { fixtureResolveUri, GalleryPage, noop } from "../storybook.agent-ui/gallery"; + +const reportUri = demoReportUri(DEMO_WORLD.reportKey); + +const untrustworthyReport: ReportViewModelPayload = { + ...demoFixtures.demoDegradedReport, + summary: { + severity: "crit", + statements: [ + { findingType: "flow", severity: "crit", reason: "unknown" }, + { findingType: "execution", severity: "crit", reason: "unknown" }, + { findingType: "liveness", severity: "crit" }, + ], + }, + findings: demoFixtures.demoDegradedReport.findings.map((finding) => + finding.type === "liveness" + ? { + ...finding, + severity: "crit", + reason: "stale", + recommendation: { code: "check_control_plane", link: "status" }, + } + : { + ...finding, + severity: "crit", + reason: "unknown", + recommendation: undefined, + attribution: undefined, + exclusions: undefined, + observations: undefined, + hedge: undefined, + anomalyWindow: undefined, + } + ), + metrics: demoFixtures.demoDegradedReport.metrics.map((metric) => + metric.id === "liveness" + ? { ...metric, value: 21 * 60_000, severity: "crit" } + : { ...metric, annotation: undefined } + ), + facts: { trustworthy: false, staleReason: "telemetry_stale" }, + links: [{ key: "status", label: "status.trigger.dev", url: "https://status.trigger.dev" }], + footer: [{ code: "check_control_plane", link: "status" }], +}; + +const STATES: Record = { + "report-view-healthy": ( + + ), + "report-view-degraded": ( + + ), + "report-view-untrustworthy": ( + + ), +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.agent-ui/fixtures.ts b/apps/webapp/app/routes/storybook.agent-ui/fixtures.ts new file mode 100644 index 00000000000..52b84e550c6 --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-ui/fixtures.ts @@ -0,0 +1,168 @@ +import type { UIMessage } from "@ai-sdk/react"; +import { + VIEW_BLOCK_VERSION, + type InvestigationBlock, + type InvestigationCapabilities, +} from "@internal/dashboard-agent-contracts"; +import { DEMO_WORLD, demoFixtures } from "~/components/dashboard-agent/demo"; +import type { TurnActivity } from "~/components/dashboard-agent/DashboardAgentMessages"; + +export function investigationBlock( + fixture: (typeof demoFixtures.demoInvestigations)[keyof typeof demoFixtures.demoInvestigations], + capabilities?: InvestigationCapabilities +): InvestigationBlock { + const { investigationId, revision, ...investigation } = fixture; + return { + type: "investigation", + id: investigationId, + revision, + version: VIEW_BLOCK_VERSION, + investigation, + ...(capabilities ? { capabilities } : {}), + }; +} + +const { + assistantMessage, + demoDiagnosisBlockFirstPass, + demoDiagnosisBlockRevised, + demoLegacyDiagnosisBlock, + failedToolPart, + pendingToolPart, + reasoningPart, + renderViewPart, + sourceUrlPart, + streamingTextPart, + textPart, + toolPart, + userMessage, +} = demoFixtures; + +/** One transcript the message gallery renders, with the turn state it belongs to. */ +export type DemoTranscript = { + messages: UIMessage[]; + activity?: TurnActivity; + /** The turn's failure. Only the section that asks for it renders one. */ + error?: string; +}; + +export const demoTranscripts = { + streamingText: { + activity: "working", + messages: [ + userMessage("stream-q", "What's failing right now?"), + assistantMessage("stream-a", [ + toolPart("query_runs", { period: "1h" }, { failures: 41 }, "query-runs-streaming"), + streamingTextPart( + "41 runs failed in the last hour, and they're all `send-order-receipt`. The error is the same every time — a 429 from the email provider, which means" + ), + ]), + ], + }, + + reasoning: { + activity: "working", + messages: [ + userMessage("inv-q", "Why did this run fail?"), + assistantMessage("inv-step1", [ + reasoningPart( + "Start from the run itself: status, attempts, and which span failed. Don't guess at a cause before reading the error." + ), + toolPart( + "get_run_details", + { runId: DEMO_WORLD.failedRunId }, + { + runId: DEMO_WORLD.failedRunId, + status: "COMPLETED_WITH_ERROR", + attempts: 3, + error: "ProviderError: 429 Too Many Requests", + }, + "get-run-details" + ), + textPart( + `\`${DEMO_WORLD.failedRunId}\` failed three times in 19 seconds, every attempt with the same error from the email provider. Three things could produce that, so I'll test them one at a time rather than settle on the first plausible one.` + ), + ]), + ], + }, + + toolInFlight: { + activity: "working", + messages: [ + userMessage("tool-q", "Check the queue depth for me."), + assistantMessage("tool-intro", [ + textPart( + `Counting what's pending across the environment first, then pulling \`${DEMO_WORLD.queue}\` on its own so we can see whether the depth is one queue or all of them.` + ), + ]), + assistantMessage("tool-a", [ + toolPart( + "run_query", + { query: "SELECT count() FROM task_runs WHERE status = 'PENDING'" }, + { rows: [{ "count()": 4812 }] }, + "run-query-done" + ), + pendingToolPart( + "get_queue", + { queue: DEMO_WORLD.queue, period: "1h" }, + "get-queue-pending" + ), + ]), + ], + }, + + errorRetry: { + error: "The chat stopped unexpectedly. Nothing was saved for this turn.", + messages: [ + userMessage("err-q", "Chart failures by task for the last week."), + assistantMessage("err-a", [ + failedToolPart( + "run_query", + { query: "SELECT task_identifier, count() FROM task_runs", period: "7d" }, + "query timed out after 30s", + "run-query-failed" + ), + ]), + ], + }, + + // Cut before the follow-up turn: this section is about the one `render_view` part, + // which carries two revisions of a diagnosis plus an envelope-less legacy block. + renderView: { + messages: [ + userMessage("res-q", "Did this happen last month too?"), + assistantMessage("res-a", [ + textPart( + `Yes — same error, same task, three weeks ago. \`${DEMO_WORLD.taskId}\` hit the same rate limit on 6 July and it was diagnosed then too; the card below is that diagnosis, replayed from this conversation rather than re-run. The retry config hasn't changed since, which is why it came back.` + ), + renderViewPart( + [demoDiagnosisBlockFirstPass, demoDiagnosisBlockRevised, demoLegacyDiagnosisBlock], + "render-view-resumed" + ), + ]), + ], + }, + + docsSources: { + messages: [ + userMessage("docs-q", "How do retries actually work? Is the delay exponential?"), + assistantMessage("docs-a", [ + toolPart( + "search_docs", + { query: "retry configuration exponential backoff" }, + { hits: 3 }, + "search-docs" + ), + textPart( + `Yes — retries back off exponentially by default. + +- \`maxAttempts\` counts the *first* attempt, so \`3\` means one try plus two retries. +- The delay is \`minTimeoutInMs * factor^(attempt - 1)\`, capped at \`maxTimeoutInMs\`. +- \`randomize: true\` adds jitter, which is what stops a whole batch retrying in lockstep — the thing that bit \`${DEMO_WORLD.taskId}\` above.` + ), + sourceUrlPart("https://trigger.dev/docs/errors-retrying", "Errors & retrying"), + sourceUrlPart("https://trigger.dev/docs/tasks/overview", "Task options"), + ]), + ], + }, +} satisfies Record; diff --git a/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx b/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx new file mode 100644 index 00000000000..5a88c0a304e --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx @@ -0,0 +1,166 @@ +import { Link } from "@remix-run/react"; +import { safeParseTriggerUri } from "@internal/dashboard-agent-contracts"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { cn } from "~/utils/cn"; +import { + GALLERY_PAGES, + groupsOnPage, + sectionsInGroup, + sectionsOnPage, + type GalleryPageId, + type GallerySection, +} from "./manifest"; + +export const noop = () => undefined; + +export const PANEL = "w-[380px]"; + +const CANVAS = "bg-background-bright"; + +export const PANEL_FRAME = "rounded-lg border border-border-bright bg-background-bright"; + +export function Missing({ what }: { what: string }) { + return ( +
+ No renderer for {what}. The manifest and the gallery are out of sync. +
+ ); +} + +export function fixtureResolveUri(uri: string): { label: string; url: string } | null { + const parsed = safeParseTriggerUri(uri); + if (!parsed.success) return null; + return { label: uri.split("/").slice(-1)[0]!, url: "#resolved-by-the-host" }; +} + +const WIDE_SECTIONS = new Set(["diagnosis-badge-matrix", "hero-fullscreen"]); + +function Section({ + section, + states, +}: { + section: GallerySection; + states: Record; +}) { + const state = states[section.sectionId]; + return ( +
+

{section.title}

+
+ {state ?? } +
+
+ ); +} + +function ThemeToggle() { + return ( +
+ {/* classic is still the default theme for most users, so it's in the pack */} + {(["classic", "dark", "light"] as const).map((theme) => ( + + ))} +
+ ); +} + +function PageLinks({ page }: { page: GalleryPageId }) { + return ( +
+ {GALLERY_PAGES.map((entry) => ( + + {entry.title} + + ))} +
+ ); +} + +function Nav({ page }: { page: GalleryPageId }) { + return ( + + ); +} + +export function GalleryPage({ + page, + states, +}: { + page: GalleryPageId; + states: Record; +}) { + const meta = GALLERY_PAGES.find((entry) => entry.id === page)!; + const sections = sectionsOnPage(page); + return ( +
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts new file mode 100644 index 00000000000..c1a30b241bb --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -0,0 +1,281 @@ +// Keep this file free of imports and JSX so plain node can read it. + +export type GalleryPageId = "chat" | "view-blocks" | "report" | "investigation" | "watch"; + +export type GalleryPage = { + id: GalleryPageId; + slug: string; + title: string; + blurb: string; +}; + +export const GALLERY_PAGES: GalleryPage[] = [ + { + id: "chat", + slug: "agent-ui", + title: "Chat UI", + blurb: + "The chat chrome: the blank-state hero, suggested prompts, the transcript and its one progress line, wake banners, watch chips and the context banner.", + }, + { + id: "view-blocks", + slug: "agent-view-blocks", + title: "View blocks", + blurb: + "The envelope rules every card obeys, the diagnosis card, the actions block and the chart card.", + }, + { + id: "report", + slug: "agent-report", + title: "Report view", + blurb: "The health report, one state per verdict it can reach.", + }, + { + id: "investigation", + slug: "agent-investigation", + title: "Investigation card", + blurb: "One card per ending an investigation can have, plus the state while it is still going.", + }, + { + id: "watch", + slug: "agent-watch", + title: "Watch card", + blurb: + "The configuration card, what a submitted card leaves in the transcript, and the wake headline.", + }, +]; + +export type GalleryGroup = + | "card" + | "diagnosis" + | "view-blocks" + | "investigation" + | "report" + | "chart" + | "watches" + | "watch-card" + | "wakes" + | "hero" + | "prompts" + | "intents" + | "messages" + | "banner"; + +export type GallerySection = { + /** DOM id and deep-link anchor. Renaming breaks the link. */ + sectionId: string; + title: string; + group: GalleryGroup; +}; + +export const GALLERY_GROUPS: { group: GalleryGroup; page: GalleryPageId; label: string }[] = [ + { group: "hero", page: "chat", label: "Blank-state hero" }, + { group: "prompts", page: "chat", label: "Suggested prompts" }, + { group: "messages", page: "chat", label: "Message-level states" }, + { group: "intents", page: "chat", label: "Intent bubbles" }, + { group: "wakes", page: "chat", label: "Wake banners" }, + { group: "watches", page: "chat", label: "Watch chips" }, + { group: "banner", page: "chat", label: "Context banner" }, + { group: "view-blocks", page: "view-blocks", label: "Envelope & actions" }, + { group: "card", page: "view-blocks", label: "Card chrome" }, + { group: "diagnosis", page: "view-blocks", label: "Diagnosis card" }, + { group: "chart", page: "view-blocks", label: "Chart card" }, + { group: "report", page: "report", label: "Report view" }, + { group: "investigation", page: "investigation", label: "Investigation card" }, + { group: "watch-card", page: "watch", label: "Watch card" }, +]; + +export const MANIFEST: GallerySection[] = [ + { sectionId: "hero-panel", title: "Side panel (380px) — no page context", group: "hero" }, + { + sectionId: "hero-panel-contextual", + title: "Side panel — failed run on the page", + group: "hero", + }, + { sectionId: "hero-fullscreen", title: "Fullscreen takeover — centred column", group: "hero" }, + { sectionId: "hero-in-chat", title: "Empty chat — hero without its own composer", group: "hero" }, + + { sectionId: "prompts-default", title: "Default set, no page context", group: "prompts" }, + { + sectionId: "prompts-contextual-fresh-failure", + title: "Contextual — fresh failure first", + group: "prompts", + }, + { sectionId: "prompts-promoted", title: "Promoted chip on top", group: "prompts" }, + { sectionId: "prompts-dismissed", title: "After a dismissal", group: "prompts" }, + + { + sectionId: "messages-streaming-text", + title: "Text part still streaming, with activity row", + group: "messages", + }, + { sectionId: "messages-reasoning", title: "Reasoning part", group: "messages" }, + { + sectionId: "messages-tool-in-flight", + title: "Tool call in flight — the turn's one progress line", + group: "messages", + }, + { + sectionId: "messages-tool-pending-pills", + title: "Progress labels — one per tool, including a card tool", + group: "messages", + }, + { + sectionId: "messages-error-retry", + title: "Failed turn — error row and retry", + group: "messages", + }, + { + sectionId: "messages-render-view", + title: "render_view part — blocks as cards", + group: "messages", + }, + { + sectionId: "messages-investigation-live", + title: "Live investigation — the card, and the turn's one progress line under it", + group: "messages", + }, + { sectionId: "messages-docs-sources", title: "Answer with source links", group: "messages" }, + + { + sectionId: "intent-navigate-filtered-runs", + title: "Navigate — runs with filters", + group: "intents", + }, + { sectionId: "intent-watch", title: "Watch started", group: "intents" }, + { + sectionId: "intent-rejected-propose-fix", + title: "Rejected — propose_fix is reserved", + group: "intents", + }, + + { sectionId: "wake-positive", title: "Positive", group: "wakes" }, + { sectionId: "wake-attention", title: "Attention", group: "wakes" }, + { sectionId: "wake-neutral-impossible", title: "Neutral — no longer possible", group: "wakes" }, + { sectionId: "wake-unverified", title: "Unverified at the window's end", group: "wakes" }, + + { sectionId: "watches-live", title: "All four states, cancellable", group: "watches" }, + + { sectionId: "banner-prod", title: "Production environment", group: "banner" }, + { sectionId: "banner-preview-long", title: "Preview branch with a long name", group: "banner" }, + + { + sectionId: "view-blocks-revisions", + title: "Three same-id revisions collapse to one card", + group: "view-blocks", + }, + { + sectionId: "view-blocks-mixed", + title: "Enveloped revisions plus a legacy block with no envelope", + group: "view-blocks", + }, + { + sectionId: "view-blocks-actions-offer", + title: "Actions block — the watch offer as buttons", + group: "view-blocks", + }, + + { sectionId: "card-compact", title: "Header plus a compact body", group: "card" }, + { sectionId: "card-roomy", title: "Header plus a roomy body", group: "card" }, + { sectionId: "card-headerless", title: "No header — body only", group: "card" }, + + { sectionId: "diagnosis-full-high", title: "Full card, high confidence", group: "diagnosis" }, + { + sectionId: "diagnosis-low-minimal", + title: "Low confidence, minimal evidence", + group: "diagnosis", + }, + { + sectionId: "diagnosis-badge-matrix", + title: "Badge matrix — every category x confidence", + group: "diagnosis", + }, + + { + sectionId: "chart-with-actions", + title: "Ranking chart with actions on the top item", + group: "chart", + }, + { sectionId: "chart-empty", title: "Empty — no data to display", group: "chart" }, + + { sectionId: "report-view-healthy", title: "Healthy — nothing to do", group: "report" }, + { + sectionId: "report-view-degraded", + title: "Degraded — env limit saturation, actions wired", + group: "report", + }, + { + sectionId: "report-view-untrustworthy", + title: "Stale telemetry — verdict unknown, numbers informational", + group: "report", + }, + + { + sectionId: "investigation-card-streaming-rev1", + title: "In progress — one hypothesis settled", + group: "investigation", + }, + { + sectionId: "investigation-card-concluded", + title: "Concluded, collapsed", + group: "investigation", + }, + { + sectionId: "investigation-card-concluded-code-grounded", + title: "Concluded, code-grounded — source citation and Show code", + group: "investigation", + }, + { + sectionId: "investigation-card-concluded-not-code-grounded", + title: "Concluded, not code-grounded — no source citation, no Show code", + group: "investigation", + }, + { + sectionId: "investigation-card-inconclusive", + title: "Inconclusive — no fix, what to check next", + group: "investigation", + }, + { + sectionId: "investigation-card-degraded", + title: "Inconclusive, degraded after a tool failure — names what it couldn't read", + group: "investigation", + }, + + { sectionId: "watch-card-compact", title: "Compact — the recommendation", group: "watch-card" }, + { sectionId: "watch-card-expanded", title: "Expanded (Customize)", group: "watch-card" }, + { sectionId: "watch-card-validation-error", title: "Validation error", group: "watch-card" }, + { sectionId: "watch-card-pending", title: "Pending create", group: "watch-card" }, + { + sectionId: "watch-card-queue-below", + title: "Customize — back below a threshold", + group: "watch-card", + }, + { + sectionId: "watch-card-queue-stalled", + title: "Customize — stopped moving (no parameter)", + group: "watch-card", + }, + { sectionId: "watch-card-confirmation", title: "Confirmation block", group: "watch-card" }, + { + sectionId: "watch-card-one-shot-satisfied", + title: "One-shot result — already true", + group: "watch-card", + }, + { + sectionId: "watch-card-toast-headline", + title: "Wake toast headline (fact first)", + group: "watch-card", + }, +]; + +export function groupsOnPage(page: GalleryPageId) { + return GALLERY_GROUPS.filter((entry) => entry.page === page); +} + +export function sectionsInGroup(group: GalleryGroup): GallerySection[] { + return MANIFEST.filter((section) => section.group === group); +} + +export function sectionsOnPage(page: GalleryPageId): GallerySection[] { + return groupsOnPage(page).flatMap((entry) => sectionsInGroup(entry.group)); +} diff --git a/apps/webapp/app/routes/storybook.agent-ui/route.tsx b/apps/webapp/app/routes/storybook.agent-ui/route.tsx index 24017b2b9cc..ae2925761db 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/route.tsx +++ b/apps/webapp/app/routes/storybook.agent-ui/route.tsx @@ -1,124 +1,337 @@ -import type { DiagnosisBlock, ViewBlock } from "@internal/dashboard-agent"; -import { ViewBlocks } from "~/components/dashboard-agent/view-catalog"; -import { Header1, Header2 } from "~/components/primitives/Headers"; -import { Paragraph } from "~/components/primitives/Paragraph"; - -// Storybook for the dashboard agent's view catalog — the blocks the agent emits -// via its render_view tool. Each example is a real block spec rendered through -// the same ViewBlocks registry the chat panel uses, at roughly panel width. - -const fullDiagnosis: DiagnosisBlock = { - type: "diagnosis", - runId: "run_a1b2c3d4e5", - summary: - "The run failed because processOrder threw on an order with no line items. The payload had an empty items array.", - category: "user_code_error", - likelyCause: - "processOrder calls order.items[0] without checking length, so an empty items array throws a TypeError before any work happens.", - confidence: "high", - evidence: [ - { - type: "error", - detail: "TypeError: Cannot read properties of undefined (reading 'sku')", - reference: "run_a1b2c3d4e5", - }, - { type: "failed_span", detail: "processOrder attempt 1 failed after 42ms" }, - { - type: "source", - detail: "The throwing line reads order.items[0].sku with no guard.", - reference: "src/trigger/processOrder.ts:18", - }, - { - type: "historical_match", - detail: "14 runs of this task hit the same error in the last 24h.", - reference: "error_emptyorder", - }, - ], - impact: - "14 runs of process-order failed with this error in the last 24 hours, all in production.", - nextSteps: [ - "Guard against an empty items array at the top of processOrder and return early.", - "Validate the payload before triggering so empty orders never reach the task.", - ], - actions: [ - { label: "View run", kind: "view_run", target: "run_a1b2c3d4e5" }, - { label: "Retries docs", kind: "docs", target: "https://trigger.dev/docs/errors-retrying" }, - ], -}; +import type { UIMessage } from "@ai-sdk/react"; +import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts"; +import { useState } from "react"; +import { demoFixtures, DemoIntentBubble } from "~/components/dashboard-agent/demo"; +import { ChatProgress, ChatTranscript, ChatTurn } from "~/components/dashboard-agent/chat-layout"; +import { DashboardAgentComposer } from "~/components/dashboard-agent/DashboardAgentComposer"; +import { DashboardAgentContextBanner } from "~/components/dashboard-agent/DashboardAgentContextBanner"; +import { DashboardAgentHero } from "~/components/dashboard-agent/DashboardAgentHero"; +import { DashboardAgentMessages } from "~/components/dashboard-agent/DashboardAgentMessages"; +import { DashboardAgentSuggestedPrompts } from "~/components/dashboard-agent/DashboardAgentSuggestedPrompts"; +import { AgentPanelColumn } from "~/components/dashboard-agent/panel-layout"; +import { liveProgress } from "~/components/dashboard-agent/progress-line"; +import type { WakeWatch } from "~/components/dashboard-agent/WakeBanner"; +import { WatchChips, type WatchChip } from "~/components/dashboard-agent/WatchChips"; +import { cn } from "~/utils/cn"; +import { demoTranscripts, investigationBlock, type DemoTranscript } from "./fixtures"; +import { fixtureResolveUri, GalleryPage, noop, PANEL_FRAME } from "./gallery"; -const externalServiceDiagnosis: DiagnosisBlock = { - type: "diagnosis", - runId: "run_f6g7h8i9j0", - summary: "chargePayment timed out waiting on the Stripe API after 30 seconds.", - category: "external_service", - likelyCause: - "The Stripe call has no timeout or retry, so a slow upstream response runs past the task's max duration.", - confidence: "medium", - evidence: [ - { - type: "error", - detail: "TimeoutError: Stripe API timed out after 30s", - reference: "run_f6g7h8i9j0", - }, - { type: "deploy", detail: "First seen on version 20260620.2", reference: "20260620.2" }, - ], - impact: "Intermittent: 3 of the last 50 charge-payment runs timed out.", - nextSteps: [ - "Wrap the Stripe call in a retry with backoff.", - "Set an explicit request timeout shorter than the task's max duration.", - ], - actions: [{ label: "View run", kind: "view_run", target: "run_f6g7h8i9j0" }], -}; +const { demoIntents, demoWatches, demoPageContexts, demoInvestigations } = demoFixtures; -const lowConfidenceDiagnosis: DiagnosisBlock = { - type: "diagnosis", - runId: "run_k1l2m3n4o5", - summary: - "The run crashed without a captured error, so the cause isn't conclusive from the available signals.", - category: "unknown", - likelyCause: - "The container exited without writing an error. This is consistent with an out-of-memory kill, but there's no OOM signal in the trace to confirm it.", - confidence: "low", - evidence: [ - { type: "failed_span", detail: "Root span ended with status CRASHED and no error payload." }, - { type: "logs", detail: "Logs stop abruptly mid-execution with no stack trace." }, - ], - nextSteps: [ - "Re-run with a larger machine to rule out out-of-memory.", - "Add logging around the last successful step to narrow where it stops.", - ], -}; +function MessageHarness({ + transcript, + withError = false, +}: { + transcript: DemoTranscript; + withError?: boolean; +}) { + return ( +
+ +
+ ); +} + +const PENDING_PILL_TOOLS: { tool: string; input: unknown }[] = [ + { tool: "render_view", input: { blocks: [{ type: "diagnosis" }] } }, + { tool: "get_report", input: { window: "24h" } }, + { tool: "get_run", input: { runId: "run_demo" } }, + { tool: "run_query", input: { query: "SELECT count() FROM task_runs" } }, + { tool: "search_docs", input: { query: "concurrency limits" } }, + { tool: "brand_new_tool", input: {} }, +]; -function Example({ title, block }: { title: string; block: ViewBlock }) { +function PendingPillsHarness() { + const lines = PENDING_PILL_TOOLS.map(({ tool, input }) => ({ + tool, + progress: liveProgress( + [ + demoFixtures.assistantMessage(`pending-${tool}`, [ + demoFixtures.pendingToolPart(tool, input, `pending-${tool}`), + ]), + ], + "working" + ), + })); return ( -
- {title} -
- -
+
+ + {lines.map(({ tool, progress }) => ( + + {progress?.label} + + ))} +
); } -export default function Story() { +function PromptsHarness({ + context, + promoted, + dismissedIds = [], +}: { + context: AgentPageContext; + promoted?: SuggestedPrompt; + dismissedIds?: string[]; +}) { + const signals = + context.signals.length > 0 ? context.signals.map((s) => s.kind).join(", ") : "no signals"; return ( -
-
- Dashboard agent UI - - Blocks the dashboard agent renders via its render_view tool, shown through the same - ViewBlocks registry the chat panel uses. The catalog has the diagnosis (failure) card, - shown here, and a chart block that runs a TRQL query live (only renders inside a - project/env, so it's not shown here). Run links resolve inside a project; here they render - as plain text. - +
+

+ {context.page.kind} — {signals} +

+
+
+
+ ); +} -
- - - -
+function HeroHarness({ + context, + promoted, + fullscreen = false, + withComposer = true, +}: { + context: AgentPageContext; + promoted?: SuggestedPrompt; + fullscreen?: boolean; + withComposer?: boolean; +}) { + const [input, setInput] = useState(""); + return ( +
+ + + } + /> + ) : undefined + } + /> + +
+ ); +} + +function LiveInvestigationHarness() { + const messages: UIMessage[] = [ + demoFixtures.userMessage("live-inv-q", "Why did this run fail?"), + demoFixtures.assistantMessage("live-inv", [ + demoFixtures.renderViewPart( + [investigationBlock(demoInvestigations.streamingRev1)], + "render-live-investigation" + ), + ]), + ]; + return ( +
+ +
+ ); +} + +const promotedPrompt: SuggestedPrompt = { + id: "sp:promo-storybook", + label: "Try the new health report", + prompt: "Give me a health report for this environment.", + source: "promoted", +}; + +// Pinned, not resolved: the fixture's signal has a fixed timestamp, so a live resolve would +// dismiss a different chip once that timestamp aged out of the freshness window. +const dismissedPromptIds = demoFixtures.demoDismissedPromptIds; + +function toWatchChip(watch: (typeof demoWatches.row)[number]): WatchChip { + return { + id: watch.id, + identity: watch.identity, + status: watch.status, + kind: watch.spec.kind, + note: watch.spec.note, + checkEveryMinutes: watch.spec.checkEveryMinutes, + expiresAt: watch.expiresAt, + }; +} + +function wakeMessage(watchId: string, outcome: "fired" | "expired", text: string): UIMessage { + return { + id: `wake:watch:${watchId}:${outcome}`, + role: "assistant", + parts: [{ type: "text", text }], + }; +} + +const wakeWatches: WakeWatch[] = [ + { + id: "watch_health", + kind: "health_recovery", + note: "prod health back to normal", + identity: "health_recovery:health", + resolution: "condition_met", + observedOutcome: { kind: "health_recovery", verified: true, severity: "ok" }, + }, + { + id: "watch_error", + kind: "error_recurrence", + note: "tell me if that TypeError comes back", + identity: "error_recurrence:a1b2c3d4e5f6", + resolution: "condition_met", + observedOutcome: { kind: "error_recurrence", verified: true, countSince: 6 }, + }, + { + id: "watch_queue_gone", + kind: "backlog_drain", + note: "tell me when the email-sends backlog clears", + identity: "backlog_drain:email-sends", + resolution: "condition_impossible", + observedOutcome: { kind: "backlog_drain", verified: true, depth: null }, + }, + { + id: "watch_unverified", + kind: "backlog_drain", + note: "tell me when the email-sends backlog clears", + identity: "backlog_drain:email-sends", + resolution: "window_completed", + observedOutcome: { kind: "backlog_drain", verified: false, depth: null }, + }, +]; + +function WakeHarness({ message, watches }: { message: UIMessage; watches?: WakeWatch[] }) { + return ( +
+
); } + +const STATES: Record = { + "hero-panel": , + "hero-panel-contextual": , + "hero-fullscreen": , + "hero-in-chat": , + + "prompts-default": , + "prompts-contextual-fresh-failure": , + "prompts-promoted": ( + + ), + "prompts-dismissed": ( + + ), + + "messages-streaming-text": , + "messages-reasoning": , + "messages-tool-in-flight": , + "messages-tool-pending-pills": , + "messages-error-retry": , + "messages-render-view": , + "messages-investigation-live": , + "messages-docs-sources": , + + "intent-navigate-filtered-runs": ( + + ), + "intent-watch": , + "intent-rejected-propose-fix": ( + + ), + + "wake-positive": ( + + ), + "wake-attention": ( + + ), + "wake-neutral-impossible": ( + + ), + "wake-unverified": ( + + ), + + "watches-live": , + + "banner-prod": ( + + ), + "banner-preview-long": ( + + ), +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx b/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx new file mode 100644 index 00000000000..500324a1ddc --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx @@ -0,0 +1,252 @@ +import type { DiagnosisBlock, ViewBlock } from "@internal/dashboard-agent"; +import { VIEW_BLOCK_VERSION } from "@internal/dashboard-agent-contracts"; +import { QueryResultsChart } from "~/components/code/QueryResultsChart"; +import { AGENT_CHART_PLOT_CLASS } from "~/components/dashboard-agent/AgentChart"; +import { ConfidenceBadge } from "~/components/dashboard-agent/agent-badges"; +import { + AgentCard, + AgentCardBody, + AgentCardHeader, + type AgentCardDensity, +} from "~/components/dashboard-agent/agent-card"; +import { DemoChartCard, demoFixtures } from "~/components/dashboard-agent/demo"; +import { RunDiagnosisCard } from "~/components/dashboard-agent/RunDiagnosisCard"; +import { ViewBlocks } from "~/components/dashboard-agent/view-catalog"; +import { GalleryPage, noop } from "../storybook.agent-ui/gallery"; + +const fullDiagnosis: DiagnosisBlock = { + type: "diagnosis", + runId: "run_a1b2c3d4e5", + summary: + "The run failed because processOrder threw on an order with no line items. The payload had an empty items array.", + category: "user_code_error", + likelyCause: + "processOrder calls order.items[0] without checking length, so an empty items array throws a TypeError before any work happens.", + confidence: "high", + evidence: [ + { + type: "error", + detail: "TypeError: Cannot read properties of undefined (reading 'sku')", + reference: "run_a1b2c3d4e5", + }, + { type: "failed_span", detail: "processOrder attempt 1 failed after 42ms" }, + { + type: "source", + detail: "The throwing line reads order.items[0].sku with no guard.", + reference: "src/trigger/processOrder.ts:18", + }, + { + type: "historical_match", + detail: "14 runs of this task hit the same error in the last 24h.", + reference: "error_emptyorder", + }, + ], + impact: + "14 runs of process-order failed with this error in the last 24 hours, all in production.", + nextSteps: [ + "Guard against an empty items array at the top of processOrder and return early.", + "Validate the payload before triggering so empty orders never reach the task.", + ], + actions: [ + { label: "View run", kind: "view_run", target: "run_a1b2c3d4e5" }, + { label: "Retries docs", kind: "docs", target: "https://trigger.dev/docs/errors-retrying" }, + ], +}; + +const externalServiceDiagnosis: DiagnosisBlock = { + type: "diagnosis", + runId: "run_f6g7h8i9j0", + summary: "chargePayment timed out waiting on the Stripe API after 30 seconds.", + category: "external_service", + likelyCause: + "The Stripe call has no timeout or retry, so a slow upstream response runs past the task's max duration.", + confidence: "medium", + evidence: [ + { + type: "error", + detail: "TimeoutError: Stripe API timed out after 30s", + reference: "run_f6g7h8i9j0", + }, + { type: "deploy", detail: "First seen on version 20260620.2", reference: "20260620.2" }, + ], + impact: "Intermittent: 3 of the last 50 charge-payment runs timed out.", + nextSteps: [ + "Wrap the Stripe call in a retry with backoff.", + "Set an explicit request timeout shorter than the task's max duration.", + ], + actions: [{ label: "View run", kind: "view_run", target: "run_f6g7h8i9j0" }], +}; + +const lowConfidenceDiagnosis: DiagnosisBlock = { + type: "diagnosis", + runId: "run_k1l2m3n4o5", + summary: + "The run crashed without a captured error, so the cause isn't conclusive from the available signals.", + category: "unknown", + likelyCause: + "The container exited without writing an error. This is consistent with an out-of-memory kill, but there's no OOM signal in the trace to confirm it.", + confidence: "low", + evidence: [ + { type: "failed_span", detail: "Root span ended with status CRASHED and no error payload." }, + { type: "logs", detail: "Logs stop abruptly mid-execution with no stack trace." }, + ], + nextSteps: [ + "Re-run with a larger machine to rule out out-of-memory.", + "Add logging around the last successful step to narrow where it stops.", + ], +}; + +const revisedDiagnosis: ViewBlock[] = [ + { + ...lowConfidenceDiagnosis, + id: "diagnosis-run_a1b2c3d4e5", + revision: 1, + version: VIEW_BLOCK_VERSION, + summary: "Revision 1 — first guess, before the logs came back. Should not render.", + }, + { + ...externalServiceDiagnosis, + id: "diagnosis-run_a1b2c3d4e5", + revision: 2, + version: VIEW_BLOCK_VERSION, + summary: "Revision 2 — narrowed to the payload, still unconfirmed. Should not render.", + }, + { + ...fullDiagnosis, + id: "diagnosis-run_a1b2c3d4e5", + revision: 3, + version: VIEW_BLOCK_VERSION, + summary: + "Revision 3 — the only card that should render: processOrder threw on an order with no line items.", + }, +]; + +const offerActionsBlock: ViewBlock = { + type: "actions", + id: "actions-offer", + revision: 0, + version: VIEW_BLOCK_VERSION, + actions: [ + { + label: "Set up a watch", + intent: { + kind: "watch", + spec: { + kind: "error_recurrence", + fingerprint: "a1b2c3", + checkEveryMinutes: 15, + maxHours: 6, + note: "the TypeError in send-order-receipt", + }, + }, + }, + { + label: "See its failed runs", + intent: { kind: "navigate", target: "trigger://proj_abc/env_abc/runs" }, + }, + ], +}; + +const DIAGNOSIS_CATEGORIES: DiagnosisBlock["category"][] = [ + "user_code_error", + "configuration", + "dependency", + "timeout", + "out_of_memory", + "rate_limit", + "external_service", + "infrastructure", + "cancellation", + "unknown", +]; + +const CONFIDENCES: DiagnosisBlock["confidence"][] = ["high", "medium", "low"]; + +const badgeMatrixBlocks: DiagnosisBlock[] = DIAGNOSIS_CATEGORIES.map((category, i) => ({ + ...demoFixtures.demoDiagnosisBlockFirstPass, + category, + confidence: CONFIDENCES[i % CONFIDENCES.length]!, + evidence: [], + nextSteps: [], + actions: undefined, + impact: undefined, +})); + +function EmptyChartCard() { + return ( + + + {demoFixtures.demoChart.title} + +
+ +
+
+ ); +} + +/** The card primitive on its own: both body densities, and a card with no header. */ +function CardChrome({ density, header }: { density?: AgentCardDensity; header?: boolean }) { + return ( + + {header ? ( + + Card header + + + ) : null} + +

+ The card owns its border, surface and insets; the transcript owns where it sits. +

+

+ A second section, so the body's density is visible as the gap between them. +

+
+
+ ); +} + +const STATES: Record = { + "view-blocks-revisions": , + "view-blocks-mixed": ( + + ), + "view-blocks-actions-offer": , + + "card-compact": , + "card-roomy": , + "card-headerless": , + + "diagnosis-full-high": , + "diagnosis-low-minimal": , + "diagnosis-badge-matrix": ( +
+ {badgeMatrixBlocks.map((block, i) => ( +
+ +
+ ))} +
+ ), + + "chart-with-actions": ( + + ), + "chart-empty": , +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.agent-watch/route.tsx b/apps/webapp/app/routes/storybook.agent-watch/route.tsx new file mode 100644 index 00000000000..8dec4e5a288 --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-watch/route.tsx @@ -0,0 +1,143 @@ +import { VIEW_BLOCK_VERSION } from "@internal/dashboard-agent-contracts"; +import { + watchDraftFor, + withFollowUp, + withThreshold, + withVariant, +} from "~/components/dashboard-agent/watch-card"; +import { WatchCard } from "~/components/dashboard-agent/WatchCard"; +import { watchConfirmationBlockBody, watchOneShotBlockBody } from "~/presenters/v3/dashboardAgent"; +import { + errorWatchRecommendation, + queueWatchRecommendation, + runWatchRecommendation, +} from "~/components/dashboard-agent/watch-recommendations"; +import { WatchResultBlock } from "~/components/dashboard-agent/WatchResultBlock"; +import { watchWakeToastTitle, type WatchWake } from "~/components/dashboard-agent/WatchWakeToast"; +import { cn } from "~/utils/cn"; +import { GalleryPage, noop, PANEL_FRAME } from "../storybook.agent-ui/gallery"; + +const queueWatchDraft = watchDraftFor(queueWatchRecommendation("email-sends")); + +const runWatchDraft = withFollowUp(watchDraftFor(runWatchRecommendation("run_a1b2c3d4e5")), { + investigateOnAttention: true, +}); + +const invalidThresholdDraft = withThreshold( + withVariant(queueWatchDraft, "queue_depth_above"), + Number.NaN +); + +const queueBelowDraft = withThreshold(withVariant(queueWatchDraft, "queue_depth_below"), 100); +const queueStalledDraft = withVariant(queueWatchDraft, "queue_stalled"); + +const WATCH_BLOCK_ENVELOPE = { + id: "watch:watch_demo", + revision: 0, + version: VIEW_BLOCK_VERSION, +} as const; + +const watchConfirmationBlock = { + ...watchConfirmationBlockBody({ + spec: queueWatchRecommendation("email-sends"), + watchId: "watch_demo", + followUp: { investigateOnAttention: true, notifyExternally: true }, + }), + ...WATCH_BLOCK_ENVELOPE, +}; + +const watchSatisfiedBlock = { + ...watchOneShotBlockBody({ + spec: runWatchRecommendation("run_a1b2c3d4e5"), + result: "satisfied", + }), + ...WATCH_BLOCK_ENVELOPE, +}; + +const toastWakes: WatchWake[] = [ + { + watchId: "watch_queue", + chatId: "chat_demo", + outcome: "fired", + note: "tell me when the email-sends backlog clears", + kind: "backlog_drain", + identity: "backlog_drain:email-sends", + resolution: "condition_met", + observedOutcome: { kind: "backlog_drain", verified: true, depth: 0 }, + }, + { + watchId: "watch_run_failed", + chatId: "chat_demo", + outcome: "fired", + note: "ping me when the nightly backfill finishes", + kind: "run_finished", + identity: "run_finished:run_a1b2c3d4e5", + resolution: "condition_met", + observedOutcome: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: 812_000, + }, + }, +]; + +function WakeToastHeadlines({ wakes }: { wakes: WatchWake[] }) { + return ( +
+ {wakes.map((wake) => ( +
+

{wake.kind}

+

{watchWakeToastTitle(wake)}

+

{wake.note}

+
+ ))} +
+ ); +} + +const STATES: Record = { + "watch-card-compact": ( + + ), + "watch-card-expanded": ( + + ), + "watch-card-validation-error": ( + + ), + "watch-card-pending": ( + + ), + "watch-card-queue-below": ( + + ), + "watch-card-queue-stalled": ( + + ), + "watch-card-confirmation": , + "watch-card-one-shot-satisfied": , + "watch-card-toast-headline": , +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.toast/route.tsx b/apps/webapp/app/routes/storybook.toast/route.tsx index e5daa0dd828..fffc53621b7 100644 --- a/apps/webapp/app/routes/storybook.toast/route.tsx +++ b/apps/webapp/app/routes/storybook.toast/route.tsx @@ -1,4 +1,4 @@ -import { Toaster, toast } from "sonner"; +import { toast } from "sonner"; import { Button } from "~/components/primitives/Buttons"; import { ToastUI } from "~/components/primitives/Toast"; @@ -17,6 +17,18 @@ export default function Story() { message="This is a long error message that wraps over multiple lines so we can test the UI." t="-" /> + + + Open chat + + } + />
- - + + } + /> + ), + { duration: Infinity } + ) + } + > + Trigger agent toast +
); } diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index e033f5d8ee2..e423318c493 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -7,10 +7,6 @@ import { requireUser } from "~/services/session.server"; import { cn } from "~/utils/cn"; const stories: Story[] = [ - { - name: "AI agent", - slug: "ai-agent", - }, { name: "Animated panel", slug: "animated-panel", @@ -163,12 +159,31 @@ const stories: Story[] = [ name: "Usage", slug: "usage", }, - // Dashboard agent section { - sectionTitle: "Dashboard agent", - name: "Agent UI", + sectionTitle: "Trigger Agent", + name: "Chat UI", slug: "agent-ui", }, + { + name: "View blocks", + slug: "agent-view-blocks", + }, + { + name: "Report view", + slug: "agent-report", + }, + { + name: "Investigation card", + slug: "agent-investigation", + }, + { + name: "Watch card", + slug: "agent-watch", + }, + { + name: "Icons & Buttons", + slug: "ai-agent", + }, // Forms section { sectionTitle: "Forms",