From 4e5fec9d8097b735db0425a3c330e3492ae8114b Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 5 Aug 2026 22:28:59 +0000 Subject: [PATCH 1/4] feat(webapp): add the agent storybook gallery The gallery pages for the agent chat, view blocks, report, investigation and watch cards, the demo fixtures behind them, and the screenshot script. This reverts commit bbdcb0881, which held these back out of the parent PR. --- .../demo/components/DemoChartCard.tsx | 32 ++ .../demo/components/DemoIntentBubble.tsx | 60 +++ .../dashboard-agent/demo/demo.test.ts | 305 ++++++++++++ .../dashboard-agent/demo/fixtures/blocks.ts | 126 +++++ .../dashboard-agent/demo/fixtures/chart.ts | 50 ++ .../dashboard-agent/demo/fixtures/index.ts | 8 + .../dashboard-agent/demo/fixtures/intents.ts | 60 +++ .../demo/fixtures/investigation.ts | 438 ++++++++++++++++++ .../dashboard-agent/demo/fixtures/messages.ts | 71 +++ .../dashboard-agent/demo/fixtures/watches.ts | 148 ++++++ .../components/dashboard-agent/demo/ids.ts | 52 ++- .../components/dashboard-agent/demo/index.ts | 7 + .../storybook.agent-investigation/route.tsx | 87 ++++ .../routes/storybook.agent-report/route.tsx | 72 +++ .../app/routes/storybook.agent-ui/fixtures.ts | 168 +++++++ .../app/routes/storybook.agent-ui/gallery.tsx | 168 +++++++ .../app/routes/storybook.agent-ui/manifest.ts | 286 ++++++++++++ .../app/routes/storybook.agent-ui/route.tsx | 434 ++++++++++++----- .../storybook.agent-view-blocks/route.tsx | 252 ++++++++++ .../routes/storybook.agent-watch/route.tsx | 143 ++++++ .../app/routes/storybook.toast/route.tsx | 43 +- apps/webapp/app/routes/storybook/route.tsx | 29 +- apps/webapp/package.json | 1 + apps/webapp/scripts/agent-ui-screenshots.ts | 327 +++++++++++++ 24 files changed, 3246 insertions(+), 121 deletions(-) create mode 100644 apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx create mode 100644 apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx create mode 100644 apps/webapp/app/components/dashboard-agent/demo/demo.test.ts create mode 100644 apps/webapp/app/components/dashboard-agent/demo/fixtures/blocks.ts create mode 100644 apps/webapp/app/components/dashboard-agent/demo/fixtures/chart.ts create mode 100644 apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts create mode 100644 apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts create mode 100644 apps/webapp/app/components/dashboard-agent/demo/fixtures/investigation.ts create mode 100644 apps/webapp/app/components/dashboard-agent/demo/fixtures/messages.ts create mode 100644 apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts create mode 100644 apps/webapp/app/components/dashboard-agent/demo/index.ts create mode 100644 apps/webapp/app/routes/storybook.agent-investigation/route.tsx create mode 100644 apps/webapp/app/routes/storybook.agent-report/route.tsx create mode 100644 apps/webapp/app/routes/storybook.agent-ui/fixtures.ts create mode 100644 apps/webapp/app/routes/storybook.agent-ui/gallery.tsx create mode 100644 apps/webapp/app/routes/storybook.agent-ui/manifest.ts create mode 100644 apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx create mode 100644 apps/webapp/app/routes/storybook.agent-watch/route.tsx create mode 100644 apps/webapp/scripts/agent-ui-screenshots.ts 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..65bc7741541 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts @@ -0,0 +1,305 @@ +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"); + } + }); +}); + +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..fb25c63c576 --- /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 } 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: demoRunUri(DEMO_WORLD.failedRunId), + 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..1c306071650 --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx @@ -0,0 +1,168 @@ +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..e6409eeca1c --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -0,0 +1,286 @@ +// Keep this file free of imports and JSX: the screenshot script is plain node and +// cannot import a Remix route module. + +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, deep-link anchor and screenshot filename. Renaming breaks both. */ + sectionId: string; + title: string; + group: GalleryGroup; + /** A state that only exists after a click. The screenshot script clicks matching text first. */ + expandText?: string; +}; + +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", + expandText: "How I worked this out", + }, + { + sectionId: "investigation-card-concluded-not-code-grounded", + title: "Concluded, not code-grounded — no source citation, no Show code", + group: "investigation", + expandText: "How I worked this out", + }, + { + 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..f3ce3ffaed8 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/route.tsx +++ b/apps/webapp/app/routes/storybook.agent-ui/route.tsx @@ -1,124 +1,338 @@ -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 { resolveSuggestedPrompts } from "~/components/dashboard-agent/suggested-prompts"; +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", +}; + +const dismissedPromptIds = resolveSuggestedPrompts(demoPageContexts.failedRun) + .slice(0, 1) + .map((prompt) => prompt.id); + +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", diff --git a/apps/webapp/package.json b/apps/webapp/package.json index be5b8b36abb..a6b86a66727 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -17,6 +17,7 @@ "start:local": "cross-env node --max-old-space-size=8192 ./build/server.js", "typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit -p ./tsconfig.check.json", "guard:runops-legacy": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsx ./scripts/runOpsLegacyGuard.ts", + "agent-ui:screenshots": "tsx ./scripts/agent-ui-screenshots.ts", "db:seed": "tsx seed.ts", "db:seed:ai-spans": "tsx seed-ai-spans.mts", "db:seed:queue-metrics": "tsx seed-queue-metrics.mts", diff --git a/apps/webapp/scripts/agent-ui-screenshots.ts b/apps/webapp/scripts/agent-ui-screenshots.ts new file mode 100644 index 00000000000..4ee4910ecff --- /dev/null +++ b/apps/webapp/scripts/agent-ui-screenshots.ts @@ -0,0 +1,327 @@ +// Writes a PNG per storybook gallery state and per stored chat, in dark and light. +// Run with `pnpm run agent-ui:screenshots`; configuration is the SCREENSHOT_* env vars below. +import { chromium, type Browser, type Locator, type Page } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { GALLERY_PAGES, MANIFEST, sectionsOnPage } from "../app/routes/storybook.agent-ui/manifest"; + +const WEBAPP_ROOT = process.cwd().endsWith(path.join("apps", "webapp")) + ? process.cwd() + : path.resolve(process.cwd(), "apps", "webapp"); + +const BASE_URL = (process.env.BASE_URL ?? "http://localhost:3030").replace(/\/$/, ""); +const EMAIL = process.env.SCREENSHOT_EMAIL ?? "local@trigger.dev"; +const ENV_PATH = process.env.SCREENSHOT_ENV_PATH; +const THEMES = (process.env.SCREENSHOT_THEMES ?? "dark,light") + .split(",") + .map((t) => t.trim()) + .filter(Boolean); +const OUT_DIR = path.resolve( + WEBAPP_ROOT, + process.env.SCREENSHOT_OUT ?? path.join("screenshots", "agent-ui") +); +const SCALE = Number(process.env.SCREENSHOT_SCALE ?? 2); +const HEADED = process.env.SCREENSHOT_HEADED === "1"; + +const CHAT_GROUP = "chats"; + +const PANEL_SELECTOR = "#dashboard-agent-panel"; + +const galleryPath = (slug: string) => `/storybook/${slug}`; + +type Capture = { + theme: string; + group: string; + sectionId: string; + title: string; + file: string; + ok: boolean; + error?: string; +}; + +const captures: Capture[] = []; + +function log(message: string) { + process.stdout.write(`${message}\n`); +} + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "untitled" + ); +} + +// Freeze motion so repeated runs produce the same bytes. +async function freezeMotion(page: Page) { + await page.addStyleTag({ + content: `*, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + animation-iteration-count: 1 !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + } + /* Caret blink shows up as a diff in composer screenshots. */ + * { caret-color: transparent !important; }`, + }); +} + +async function applyTheme(page: Page, theme: string) { + await page.evaluate((value) => { + document.documentElement.setAttribute("data-theme", value); + }, theme); +} + +async function open(page: Page, pathname: string, theme: string) { + await page.goto(`${BASE_URL}${pathname}`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle").catch(() => { + // A page with a live stream never goes idle; the settle below covers it. + }); + await applyTheme(page, theme); + await freezeMotion(page); + await page.waitForTimeout(400); +} + +// Local dev redirects straight to the magic link, so submitting the form logs us in. +async function login(page: Page) { + await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded" }); + if (!new URL(page.url()).pathname.startsWith("/login")) { + log("Already authenticated."); + return; + } + await page.fill('input[type="email"]', EMAIL); + await page.getByRole("button", { name: /continue with email/i }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 60_000 }); + log(`Logged in as ${EMAIL}.`); +} + +async function capture( + target: Locator, + { theme, group, sectionId, title }: Omit +) { + const file = path.join(theme, group, `${sectionId}.png`); + const absolute = path.join(OUT_DIR, file); + try { + await mkdir(path.dirname(absolute), { recursive: true }); + await target.scrollIntoViewIfNeeded({ timeout: 10_000 }); + await target.screenshot({ path: absolute, animations: "disabled" }); + captures.push({ theme, group, sectionId, title, file, ok: true }); + log(` ok ${file}`); + } catch (error) { + const message = error instanceof Error ? error.message.split("\n")[0]! : String(error); + captures.push({ theme, group, sectionId, title, file, ok: false, error: message }); + log(` FAIL ${file} — ${message}`); + } +} + +async function shootGallery(page: Page, theme: string) { + for (const galleryPage of GALLERY_PAGES) { + await shootGalleryPage(page, theme, galleryPage); + } +} + +async function shootGalleryPage( + page: Page, + theme: string, + galleryPage: (typeof GALLERY_PAGES)[number] +) { + const pagePath = galleryPath(galleryPage.slug); + log(`\nGallery · ${galleryPage.title} · ${theme}`); + await open(page, pagePath, theme); + + const pathname = new URL(page.url()).pathname; + if (pathname !== pagePath) { + throw new Error( + `${pagePath} redirected to ${pathname}. The storybook route requires an admin user — ` + + `check that ${EMAIL} has admin set (the local seed does).` + ); + } + + for (const section of sectionsOnPage(galleryPage.id)) { + const target = page.locator(`#${section.sectionId}`); + if (section.expandText) { + await target + .getByText(section.expandText, { exact: true }) + .first() + .click({ timeout: 5_000 }) + .catch(() => { + log(` note ${section.sectionId}: nothing matched "${section.expandText}" to expand`); + }); + await page.waitForTimeout(150); + } + await capture(target, { + theme, + group: section.group, + sectionId: section.sectionId, + title: section.title, + }); + } +} + +async function openPanel(page: Page): Promise { + const panel = page.locator(PANEL_SELECTOR); + if (!(await panel.count())) { + const launcher = page.getByRole("button", { name: "Open chat" }); + if (!(await launcher.count())) { + throw new Error( + "No agent launcher on this page. The panel needs DASHBOARD_AGENT_ENABLED=1 (or " + + "DASHBOARD_AGENT_ADMIN_PREVIEW=1) and an env-scoped path in SCREENSHOT_ENV_PATH." + ); + } + await launcher.click(); + await panel.waitFor({ state: "visible", timeout: 15_000 }); + await page.waitForTimeout(300); + } + return panel; +} + +async function showHistory(page: Page, panel: Locator) { + const rows = panel.locator("ol > li"); + if (await rows.count()) return; + await panel.getByRole("button", { name: "History" }).click(); + await rows.first().waitFor({ state: "visible", timeout: 15_000 }); +} + +// Indices, not text: a row's accessible name carries a timestamp and titles can share a prefix. +async function chatRows(panel: Locator): Promise<{ index: number; title: string }[]> { + const rows = panel.locator("ol > li"); + const count = await rows.count(); + const found: { index: number; title: string }[] = []; + for (let index = 0; index < count; index++) { + const title = ((await rows.nth(index).locator("span").first().textContent()) ?? "").trim(); + found.push({ index, title }); + } + return found; +} + +async function shootChats(page: Page, theme: string) { + if (!ENV_PATH) return; + log(`\nChats · ${theme}`); + await open(page, ENV_PATH, theme); + + let panel = await openPanel(page); + await showHistory(page, panel); + const rows = await chatRows(panel); + + if (rows.length === 0) { + throw new Error( + "No conversations in the panel history. Point SCREENSHOT_ENV_PATH at a " + + "project whose org has chats, or talk to the agent there first." + ); + } + log(` ${rows.length} conversations`); + + for (const { index, title } of rows) { + // Re-resolve every time: opening a chat leaves the history view and the panel can remount. + panel = await openPanel(page); + await showHistory(page, panel); + await panel.locator("ol > li").nth(index).locator("button").first().click(); + await page.waitForTimeout(600); + await freezeMotion(page); + await capture(panel, { + theme, + group: CHAT_GROUP, + sectionId: slugify(title), + title, + }); + } +} + +function summarise() { + const failed = captures.filter((c) => !c.ok); + const byGroup = new Map(); + for (const c of captures) { + const key = `${c.theme}/${c.group}`; + const row = byGroup.get(key) ?? { ok: 0, failed: 0 }; + if (c.ok) row.ok++; + else row.failed++; + byGroup.set(key, row); + } + + log("\nSummary"); + const width = Math.max(...[...byGroup.keys()].map((k) => k.length), 5); + for (const [key, row] of byGroup) { + log(` ${key.padEnd(width)} ${String(row.ok).padStart(3)} ok ${row.failed} failed`); + } + log(` ${"total".padEnd(width)} ${captures.length - failed.length} ok ${failed.length} failed`); + log(`\nOutput: ${OUT_DIR}`); + + if (failed.length > 0) { + log("\nFailures:"); + for (const c of failed) log(` ${c.file}: ${c.error}`); + } + return failed.length; +} + +async function main() { + log(`Base URL: ${BASE_URL}`); + log(`Themes: ${THEMES.join(", ")}`); + log(`Sections: ${MANIFEST.length} gallery states across ${GALLERY_PAGES.length} pages`); + log(`Env path: ${ENV_PATH ?? "(not set — skipping the chat walk)"}`); + + let browser: Browser | undefined; + const fatal: string[] = []; + try { + browser = await chromium.launch({ headless: !HEADED }); + const context = await browser.newContext({ + viewport: { width: 1600, height: 1000 }, + deviceScaleFactor: SCALE, + // Pin the zone so relative timestamps are comparable between machines. + timezoneId: "UTC", + locale: "en-US", + }); + const page = await context.newPage(); + page.setDefaultTimeout(20_000); + + await login(page); + + for (const theme of THEMES) { + for (const phase of [shootGallery, shootChats]) { + try { + await phase(page, theme); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fatal.push(`${phase.name} (${theme}): ${message}`); + log(`\n SKIPPED ${phase.name} (${theme}) — ${message}`); + } + } + } + } finally { + await browser?.close(); + } + + await mkdir(OUT_DIR, { recursive: true }); + await writeFile( + path.join(OUT_DIR, "manifest.json"), + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + baseUrl: BASE_URL, + themes: THEMES, + envPath: ENV_PATH ?? null, + deviceScaleFactor: SCALE, + captures, + skipped: fatal, + }, + null, + 2 + )}\n` + ); + + const failedCount = summarise(); + if (fatal.length > 0) { + log("\nSkipped phases:"); + for (const message of fatal) log(` ${message}`); + } + if (failedCount > 0 || fatal.length > 0 || captures.length === 0) { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 1cee6105f085939d833842d63f1a466ecdb0ed82 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 5 Aug 2026 22:38:34 +0000 Subject: [PATCH 2/4] chore(webapp): drop the gallery screenshot script --- apps/webapp/package.json | 1 - apps/webapp/scripts/agent-ui-screenshots.ts | 327 -------------------- 2 files changed, 328 deletions(-) delete mode 100644 apps/webapp/scripts/agent-ui-screenshots.ts diff --git a/apps/webapp/package.json b/apps/webapp/package.json index a6b86a66727..be5b8b36abb 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -17,7 +17,6 @@ "start:local": "cross-env node --max-old-space-size=8192 ./build/server.js", "typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit -p ./tsconfig.check.json", "guard:runops-legacy": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsx ./scripts/runOpsLegacyGuard.ts", - "agent-ui:screenshots": "tsx ./scripts/agent-ui-screenshots.ts", "db:seed": "tsx seed.ts", "db:seed:ai-spans": "tsx seed-ai-spans.mts", "db:seed:queue-metrics": "tsx seed-queue-metrics.mts", diff --git a/apps/webapp/scripts/agent-ui-screenshots.ts b/apps/webapp/scripts/agent-ui-screenshots.ts deleted file mode 100644 index 4ee4910ecff..00000000000 --- a/apps/webapp/scripts/agent-ui-screenshots.ts +++ /dev/null @@ -1,327 +0,0 @@ -// Writes a PNG per storybook gallery state and per stored chat, in dark and light. -// Run with `pnpm run agent-ui:screenshots`; configuration is the SCREENSHOT_* env vars below. -import { chromium, type Browser, type Locator, type Page } from "@playwright/test"; -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { GALLERY_PAGES, MANIFEST, sectionsOnPage } from "../app/routes/storybook.agent-ui/manifest"; - -const WEBAPP_ROOT = process.cwd().endsWith(path.join("apps", "webapp")) - ? process.cwd() - : path.resolve(process.cwd(), "apps", "webapp"); - -const BASE_URL = (process.env.BASE_URL ?? "http://localhost:3030").replace(/\/$/, ""); -const EMAIL = process.env.SCREENSHOT_EMAIL ?? "local@trigger.dev"; -const ENV_PATH = process.env.SCREENSHOT_ENV_PATH; -const THEMES = (process.env.SCREENSHOT_THEMES ?? "dark,light") - .split(",") - .map((t) => t.trim()) - .filter(Boolean); -const OUT_DIR = path.resolve( - WEBAPP_ROOT, - process.env.SCREENSHOT_OUT ?? path.join("screenshots", "agent-ui") -); -const SCALE = Number(process.env.SCREENSHOT_SCALE ?? 2); -const HEADED = process.env.SCREENSHOT_HEADED === "1"; - -const CHAT_GROUP = "chats"; - -const PANEL_SELECTOR = "#dashboard-agent-panel"; - -const galleryPath = (slug: string) => `/storybook/${slug}`; - -type Capture = { - theme: string; - group: string; - sectionId: string; - title: string; - file: string; - ok: boolean; - error?: string; -}; - -const captures: Capture[] = []; - -function log(message: string) { - process.stdout.write(`${message}\n`); -} - -function slugify(value: string): string { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "untitled" - ); -} - -// Freeze motion so repeated runs produce the same bytes. -async function freezeMotion(page: Page) { - await page.addStyleTag({ - content: `*, *::before, *::after { - animation-duration: 0s !important; - animation-delay: 0s !important; - animation-iteration-count: 1 !important; - transition-duration: 0s !important; - transition-delay: 0s !important; - } - /* Caret blink shows up as a diff in composer screenshots. */ - * { caret-color: transparent !important; }`, - }); -} - -async function applyTheme(page: Page, theme: string) { - await page.evaluate((value) => { - document.documentElement.setAttribute("data-theme", value); - }, theme); -} - -async function open(page: Page, pathname: string, theme: string) { - await page.goto(`${BASE_URL}${pathname}`, { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle").catch(() => { - // A page with a live stream never goes idle; the settle below covers it. - }); - await applyTheme(page, theme); - await freezeMotion(page); - await page.waitForTimeout(400); -} - -// Local dev redirects straight to the magic link, so submitting the form logs us in. -async function login(page: Page) { - await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded" }); - if (!new URL(page.url()).pathname.startsWith("/login")) { - log("Already authenticated."); - return; - } - await page.fill('input[type="email"]', EMAIL); - await page.getByRole("button", { name: /continue with email/i }).click(); - await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 60_000 }); - log(`Logged in as ${EMAIL}.`); -} - -async function capture( - target: Locator, - { theme, group, sectionId, title }: Omit -) { - const file = path.join(theme, group, `${sectionId}.png`); - const absolute = path.join(OUT_DIR, file); - try { - await mkdir(path.dirname(absolute), { recursive: true }); - await target.scrollIntoViewIfNeeded({ timeout: 10_000 }); - await target.screenshot({ path: absolute, animations: "disabled" }); - captures.push({ theme, group, sectionId, title, file, ok: true }); - log(` ok ${file}`); - } catch (error) { - const message = error instanceof Error ? error.message.split("\n")[0]! : String(error); - captures.push({ theme, group, sectionId, title, file, ok: false, error: message }); - log(` FAIL ${file} — ${message}`); - } -} - -async function shootGallery(page: Page, theme: string) { - for (const galleryPage of GALLERY_PAGES) { - await shootGalleryPage(page, theme, galleryPage); - } -} - -async function shootGalleryPage( - page: Page, - theme: string, - galleryPage: (typeof GALLERY_PAGES)[number] -) { - const pagePath = galleryPath(galleryPage.slug); - log(`\nGallery · ${galleryPage.title} · ${theme}`); - await open(page, pagePath, theme); - - const pathname = new URL(page.url()).pathname; - if (pathname !== pagePath) { - throw new Error( - `${pagePath} redirected to ${pathname}. The storybook route requires an admin user — ` + - `check that ${EMAIL} has admin set (the local seed does).` - ); - } - - for (const section of sectionsOnPage(galleryPage.id)) { - const target = page.locator(`#${section.sectionId}`); - if (section.expandText) { - await target - .getByText(section.expandText, { exact: true }) - .first() - .click({ timeout: 5_000 }) - .catch(() => { - log(` note ${section.sectionId}: nothing matched "${section.expandText}" to expand`); - }); - await page.waitForTimeout(150); - } - await capture(target, { - theme, - group: section.group, - sectionId: section.sectionId, - title: section.title, - }); - } -} - -async function openPanel(page: Page): Promise { - const panel = page.locator(PANEL_SELECTOR); - if (!(await panel.count())) { - const launcher = page.getByRole("button", { name: "Open chat" }); - if (!(await launcher.count())) { - throw new Error( - "No agent launcher on this page. The panel needs DASHBOARD_AGENT_ENABLED=1 (or " + - "DASHBOARD_AGENT_ADMIN_PREVIEW=1) and an env-scoped path in SCREENSHOT_ENV_PATH." - ); - } - await launcher.click(); - await panel.waitFor({ state: "visible", timeout: 15_000 }); - await page.waitForTimeout(300); - } - return panel; -} - -async function showHistory(page: Page, panel: Locator) { - const rows = panel.locator("ol > li"); - if (await rows.count()) return; - await panel.getByRole("button", { name: "History" }).click(); - await rows.first().waitFor({ state: "visible", timeout: 15_000 }); -} - -// Indices, not text: a row's accessible name carries a timestamp and titles can share a prefix. -async function chatRows(panel: Locator): Promise<{ index: number; title: string }[]> { - const rows = panel.locator("ol > li"); - const count = await rows.count(); - const found: { index: number; title: string }[] = []; - for (let index = 0; index < count; index++) { - const title = ((await rows.nth(index).locator("span").first().textContent()) ?? "").trim(); - found.push({ index, title }); - } - return found; -} - -async function shootChats(page: Page, theme: string) { - if (!ENV_PATH) return; - log(`\nChats · ${theme}`); - await open(page, ENV_PATH, theme); - - let panel = await openPanel(page); - await showHistory(page, panel); - const rows = await chatRows(panel); - - if (rows.length === 0) { - throw new Error( - "No conversations in the panel history. Point SCREENSHOT_ENV_PATH at a " + - "project whose org has chats, or talk to the agent there first." - ); - } - log(` ${rows.length} conversations`); - - for (const { index, title } of rows) { - // Re-resolve every time: opening a chat leaves the history view and the panel can remount. - panel = await openPanel(page); - await showHistory(page, panel); - await panel.locator("ol > li").nth(index).locator("button").first().click(); - await page.waitForTimeout(600); - await freezeMotion(page); - await capture(panel, { - theme, - group: CHAT_GROUP, - sectionId: slugify(title), - title, - }); - } -} - -function summarise() { - const failed = captures.filter((c) => !c.ok); - const byGroup = new Map(); - for (const c of captures) { - const key = `${c.theme}/${c.group}`; - const row = byGroup.get(key) ?? { ok: 0, failed: 0 }; - if (c.ok) row.ok++; - else row.failed++; - byGroup.set(key, row); - } - - log("\nSummary"); - const width = Math.max(...[...byGroup.keys()].map((k) => k.length), 5); - for (const [key, row] of byGroup) { - log(` ${key.padEnd(width)} ${String(row.ok).padStart(3)} ok ${row.failed} failed`); - } - log(` ${"total".padEnd(width)} ${captures.length - failed.length} ok ${failed.length} failed`); - log(`\nOutput: ${OUT_DIR}`); - - if (failed.length > 0) { - log("\nFailures:"); - for (const c of failed) log(` ${c.file}: ${c.error}`); - } - return failed.length; -} - -async function main() { - log(`Base URL: ${BASE_URL}`); - log(`Themes: ${THEMES.join(", ")}`); - log(`Sections: ${MANIFEST.length} gallery states across ${GALLERY_PAGES.length} pages`); - log(`Env path: ${ENV_PATH ?? "(not set — skipping the chat walk)"}`); - - let browser: Browser | undefined; - const fatal: string[] = []; - try { - browser = await chromium.launch({ headless: !HEADED }); - const context = await browser.newContext({ - viewport: { width: 1600, height: 1000 }, - deviceScaleFactor: SCALE, - // Pin the zone so relative timestamps are comparable between machines. - timezoneId: "UTC", - locale: "en-US", - }); - const page = await context.newPage(); - page.setDefaultTimeout(20_000); - - await login(page); - - for (const theme of THEMES) { - for (const phase of [shootGallery, shootChats]) { - try { - await phase(page, theme); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - fatal.push(`${phase.name} (${theme}): ${message}`); - log(`\n SKIPPED ${phase.name} (${theme}) — ${message}`); - } - } - } - } finally { - await browser?.close(); - } - - await mkdir(OUT_DIR, { recursive: true }); - await writeFile( - path.join(OUT_DIR, "manifest.json"), - `${JSON.stringify( - { - generatedAt: new Date().toISOString(), - baseUrl: BASE_URL, - themes: THEMES, - envPath: ENV_PATH ?? null, - deviceScaleFactor: SCALE, - captures, - skipped: fatal, - }, - null, - 2 - )}\n` - ); - - const failedCount = summarise(); - if (fatal.length > 0) { - log("\nSkipped phases:"); - for (const message of fatal) log(` ${message}`); - } - if (failedCount > 0 || fatal.length > 0 || captures.length === 0) { - process.exitCode = 1; - } -} - -main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); From 1b6dfb128d65c1b23f4225a8334e9d2252568d57 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 6 Aug 2026 00:56:11 +0000 Subject: [PATCH 3/4] fix(webapp): point the filtered-runs demo intent at the runs collection --- .../app/components/dashboard-agent/demo/demo.test.ts | 9 +++++++++ .../components/dashboard-agent/demo/fixtures/intents.ts | 4 ++-- apps/webapp/app/routes/storybook.agent-ui/gallery.tsx | 6 ++---- apps/webapp/app/routes/storybook.agent-ui/manifest.ts | 7 +++---- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts index 65bc7741541..3b40087e01f 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts @@ -211,6 +211,15 @@ describe("intent fixtures", () => { 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", () => { diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts index fb25c63c576..3fea9f20950 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts @@ -1,5 +1,5 @@ import { isExecutableIntent, type AgentIntent } from "@internal/dashboard-agent-contracts"; -import { DEMO_WORLD, demoRunUri } from "../ids"; +import { DEMO_WORLD, demoRunUri, demoRunsUri } from "../ids"; import { demoBacklogDrainWatch } from "./watches"; export type DemoIntent = { @@ -19,7 +19,7 @@ const demoIntent = (intent: AgentIntent, outcome: string, deepLinkLabel?: string export const demoNavigateToFailedRuns = demoIntent( { kind: "navigate", - target: demoRunUri(DEMO_WORLD.failedRunId), + target: demoRunsUri(), filters: { statuses: ["COMPLETED_WITH_ERROR"], period: "24h", diff --git a/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx b/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx index 1c306071650..5a88c0a304e 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx +++ b/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx @@ -141,14 +141,12 @@ export function GalleryPage({ {meta.blurb} {sections.length} states, rendered in isolation at panel width (380px) from the demo fixtures in{" "} app/components/dashboard-agent/demo/fixtures. - The list lives in manifest.ts, which the - screenshot script walks too. + The list lives in manifest.ts. Run ids, queues, errors and reports are fabricated. Deep links resolve inside a project, so here they render as plain text or navigate nowhere. The theme buttons flip{" "} - data-theme on the root element, which is how - the screenshot pack captures both themes. + data-theme on the root element.
diff --git a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts index e6409eeca1c..0ff4f99319b 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -1,5 +1,4 @@ -// Keep this file free of imports and JSX: the screenshot script is plain node and -// cannot import a Remix route module. +// Keep this file free of imports and JSX so plain node can read it. export type GalleryPageId = "chat" | "view-blocks" | "report" | "investigation" | "watch"; @@ -63,11 +62,11 @@ export type GalleryGroup = | "banner"; export type GallerySection = { - /** DOM id, deep-link anchor and screenshot filename. Renaming breaks both. */ + /** DOM id and deep-link anchor. Renaming breaks the link. */ sectionId: string; title: string; group: GalleryGroup; - /** A state that only exists after a click. The screenshot script clicks matching text first. */ + /** A state that only exists after a click. */ expandText?: string; }; From 662e92f3f7ddba280fda25325d3b5d03cc6653aa Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 6 Aug 2026 01:06:10 +0000 Subject: [PATCH 4/4] fix(webapp): pin the dismissed prompt in the gallery and drop the dead expand field --- apps/webapp/app/routes/storybook.agent-ui/manifest.ts | 4 ---- apps/webapp/app/routes/storybook.agent-ui/route.tsx | 7 +++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts index 0ff4f99319b..c1a30b241bb 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -66,8 +66,6 @@ export type GallerySection = { sectionId: string; title: string; group: GalleryGroup; - /** A state that only exists after a click. */ - expandText?: string; }; export const GALLERY_GROUPS: { group: GalleryGroup; page: GalleryPageId; label: string }[] = [ @@ -226,13 +224,11 @@ export const MANIFEST: GallerySection[] = [ sectionId: "investigation-card-concluded-code-grounded", title: "Concluded, code-grounded — source citation and Show code", group: "investigation", - expandText: "How I worked this out", }, { sectionId: "investigation-card-concluded-not-code-grounded", title: "Concluded, not code-grounded — no source citation, no Show code", group: "investigation", - expandText: "How I worked this out", }, { sectionId: "investigation-card-inconclusive", diff --git a/apps/webapp/app/routes/storybook.agent-ui/route.tsx b/apps/webapp/app/routes/storybook.agent-ui/route.tsx index f3ce3ffaed8..ae2925761db 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/route.tsx +++ b/apps/webapp/app/routes/storybook.agent-ui/route.tsx @@ -10,7 +10,6 @@ import { DashboardAgentMessages } from "~/components/dashboard-agent/DashboardAg import { DashboardAgentSuggestedPrompts } from "~/components/dashboard-agent/DashboardAgentSuggestedPrompts"; import { AgentPanelColumn } from "~/components/dashboard-agent/panel-layout"; import { liveProgress } from "~/components/dashboard-agent/progress-line"; -import { resolveSuggestedPrompts } from "~/components/dashboard-agent/suggested-prompts"; import type { WakeWatch } from "~/components/dashboard-agent/WakeBanner"; import { WatchChips, type WatchChip } from "~/components/dashboard-agent/WatchChips"; import { cn } from "~/utils/cn"; @@ -175,9 +174,9 @@ const promotedPrompt: SuggestedPrompt = { source: "promoted", }; -const dismissedPromptIds = resolveSuggestedPrompts(demoPageContexts.failedRun) - .slice(0, 1) - .map((prompt) => prompt.id); +// 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 {