-
Dashboard agent UI
-
- Blocks the dashboard agent renders via its render_view tool, shown through the same
- ViewBlocks registry the chat panel uses. The catalog has the diagnosis (failure) card,
- shown here, and a chart block that runs a TRQL query live (only renders inside a
- project/env, so it's not shown here). Run links resolve inside a project; here they render
- as plain text.
-
+
+
+ {context.page.kind} — {signals}
+
+
+
+
+ );
+}
-
-
-
-
-
+function HeroHarness({
+ context,
+ promoted,
+ fullscreen = false,
+ withComposer = true,
+}: {
+ context: AgentPageContext;
+ promoted?: SuggestedPrompt;
+ fullscreen?: boolean;
+ withComposer?: boolean;
+}) {
+ const [input, setInput] = useState("");
+ return (
+
+
+
+ }
+ />
+ ) : undefined
+ }
+ />
+
+
+ );
+}
+
+function LiveInvestigationHarness() {
+ const messages: UIMessage[] = [
+ demoFixtures.userMessage("live-inv-q", "Why did this run fail?"),
+ demoFixtures.assistantMessage("live-inv", [
+ demoFixtures.renderViewPart(
+ [investigationBlock(demoInvestigations.streamingRev1)],
+ "render-live-investigation"
+ ),
+ ]),
+ ];
+ return (
+
+
+
+ );
+}
+
+const promotedPrompt: SuggestedPrompt = {
+ id: "sp:promo-storybook",
+ label: "Try the new health report",
+ prompt: "Give me a health report for this environment.",
+ source: "promoted",
+};
+
+// Pinned, not resolved: the fixture's signal has a fixed timestamp, so a live resolve would
+// dismiss a different chip once that timestamp aged out of the freshness window.
+const dismissedPromptIds = demoFixtures.demoDismissedPromptIds;
+
+function toWatchChip(watch: (typeof demoWatches.row)[number]): WatchChip {
+ return {
+ id: watch.id,
+ identity: watch.identity,
+ status: watch.status,
+ kind: watch.spec.kind,
+ note: watch.spec.note,
+ checkEveryMinutes: watch.spec.checkEveryMinutes,
+ expiresAt: watch.expiresAt,
+ };
+}
+
+function wakeMessage(watchId: string, outcome: "fired" | "expired", text: string): UIMessage {
+ return {
+ id: `wake:watch:${watchId}:${outcome}`,
+ role: "assistant",
+ parts: [{ type: "text", text }],
+ };
+}
+
+const wakeWatches: WakeWatch[] = [
+ {
+ id: "watch_health",
+ kind: "health_recovery",
+ note: "prod health back to normal",
+ identity: "health_recovery:health",
+ resolution: "condition_met",
+ observedOutcome: { kind: "health_recovery", verified: true, severity: "ok" },
+ },
+ {
+ id: "watch_error",
+ kind: "error_recurrence",
+ note: "tell me if that TypeError comes back",
+ identity: "error_recurrence:a1b2c3d4e5f6",
+ resolution: "condition_met",
+ observedOutcome: { kind: "error_recurrence", verified: true, countSince: 6 },
+ },
+ {
+ id: "watch_queue_gone",
+ kind: "backlog_drain",
+ note: "tell me when the email-sends backlog clears",
+ identity: "backlog_drain:email-sends",
+ resolution: "condition_impossible",
+ observedOutcome: { kind: "backlog_drain", verified: true, depth: null },
+ },
+ {
+ id: "watch_unverified",
+ kind: "backlog_drain",
+ note: "tell me when the email-sends backlog clears",
+ identity: "backlog_drain:email-sends",
+ resolution: "window_completed",
+ observedOutcome: { kind: "backlog_drain", verified: false, depth: null },
+ },
+];
+
+function WakeHarness({ message, watches }: { message: UIMessage; watches?: WakeWatch[] }) {
+ return (
+
+
);
}
+
+const STATES: Record
= {
+ "hero-panel": ,
+ "hero-panel-contextual": ,
+ "hero-fullscreen": ,
+ "hero-in-chat": ,
+
+ "prompts-default": ,
+ "prompts-contextual-fresh-failure": ,
+ "prompts-promoted": (
+
+ ),
+ "prompts-dismissed": (
+
+ ),
+
+ "messages-streaming-text": ,
+ "messages-reasoning": ,
+ "messages-tool-in-flight": ,
+ "messages-tool-pending-pills": ,
+ "messages-error-retry": ,
+ "messages-render-view": ,
+ "messages-investigation-live": ,
+ "messages-docs-sources": ,
+
+ "intent-navigate-filtered-runs": (
+
+ ),
+ "intent-watch": ,
+ "intent-rejected-propose-fix": (
+
+ ),
+
+ "wake-positive": (
+
+ ),
+ "wake-attention": (
+
+ ),
+ "wake-neutral-impossible": (
+
+ ),
+ "wake-unverified": (
+
+ ),
+
+ "watches-live": ,
+
+ "banner-prod": (
+
+ ),
+ "banner-preview-long": (
+
+ ),
+};
+
+export default function Story() {
+ return ;
+}
diff --git a/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx b/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx
new file mode 100644
index 00000000000..500324a1ddc
--- /dev/null
+++ b/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx
@@ -0,0 +1,252 @@
+import type { DiagnosisBlock, ViewBlock } from "@internal/dashboard-agent";
+import { VIEW_BLOCK_VERSION } from "@internal/dashboard-agent-contracts";
+import { QueryResultsChart } from "~/components/code/QueryResultsChart";
+import { AGENT_CHART_PLOT_CLASS } from "~/components/dashboard-agent/AgentChart";
+import { ConfidenceBadge } from "~/components/dashboard-agent/agent-badges";
+import {
+ AgentCard,
+ AgentCardBody,
+ AgentCardHeader,
+ type AgentCardDensity,
+} from "~/components/dashboard-agent/agent-card";
+import { DemoChartCard, demoFixtures } from "~/components/dashboard-agent/demo";
+import { RunDiagnosisCard } from "~/components/dashboard-agent/RunDiagnosisCard";
+import { ViewBlocks } from "~/components/dashboard-agent/view-catalog";
+import { GalleryPage, noop } from "../storybook.agent-ui/gallery";
+
+const fullDiagnosis: DiagnosisBlock = {
+ type: "diagnosis",
+ runId: "run_a1b2c3d4e5",
+ summary:
+ "The run failed because processOrder threw on an order with no line items. The payload had an empty items array.",
+ category: "user_code_error",
+ likelyCause:
+ "processOrder calls order.items[0] without checking length, so an empty items array throws a TypeError before any work happens.",
+ confidence: "high",
+ evidence: [
+ {
+ type: "error",
+ detail: "TypeError: Cannot read properties of undefined (reading 'sku')",
+ reference: "run_a1b2c3d4e5",
+ },
+ { type: "failed_span", detail: "processOrder attempt 1 failed after 42ms" },
+ {
+ type: "source",
+ detail: "The throwing line reads order.items[0].sku with no guard.",
+ reference: "src/trigger/processOrder.ts:18",
+ },
+ {
+ type: "historical_match",
+ detail: "14 runs of this task hit the same error in the last 24h.",
+ reference: "error_emptyorder",
+ },
+ ],
+ impact:
+ "14 runs of process-order failed with this error in the last 24 hours, all in production.",
+ nextSteps: [
+ "Guard against an empty items array at the top of processOrder and return early.",
+ "Validate the payload before triggering so empty orders never reach the task.",
+ ],
+ actions: [
+ { label: "View run", kind: "view_run", target: "run_a1b2c3d4e5" },
+ { label: "Retries docs", kind: "docs", target: "https://trigger.dev/docs/errors-retrying" },
+ ],
+};
+
+const externalServiceDiagnosis: DiagnosisBlock = {
+ type: "diagnosis",
+ runId: "run_f6g7h8i9j0",
+ summary: "chargePayment timed out waiting on the Stripe API after 30 seconds.",
+ category: "external_service",
+ likelyCause:
+ "The Stripe call has no timeout or retry, so a slow upstream response runs past the task's max duration.",
+ confidence: "medium",
+ evidence: [
+ {
+ type: "error",
+ detail: "TimeoutError: Stripe API timed out after 30s",
+ reference: "run_f6g7h8i9j0",
+ },
+ { type: "deploy", detail: "First seen on version 20260620.2", reference: "20260620.2" },
+ ],
+ impact: "Intermittent: 3 of the last 50 charge-payment runs timed out.",
+ nextSteps: [
+ "Wrap the Stripe call in a retry with backoff.",
+ "Set an explicit request timeout shorter than the task's max duration.",
+ ],
+ actions: [{ label: "View run", kind: "view_run", target: "run_f6g7h8i9j0" }],
+};
+
+const lowConfidenceDiagnosis: DiagnosisBlock = {
+ type: "diagnosis",
+ runId: "run_k1l2m3n4o5",
+ summary:
+ "The run crashed without a captured error, so the cause isn't conclusive from the available signals.",
+ category: "unknown",
+ likelyCause:
+ "The container exited without writing an error. This is consistent with an out-of-memory kill, but there's no OOM signal in the trace to confirm it.",
+ confidence: "low",
+ evidence: [
+ { type: "failed_span", detail: "Root span ended with status CRASHED and no error payload." },
+ { type: "logs", detail: "Logs stop abruptly mid-execution with no stack trace." },
+ ],
+ nextSteps: [
+ "Re-run with a larger machine to rule out out-of-memory.",
+ "Add logging around the last successful step to narrow where it stops.",
+ ],
+};
+
+const revisedDiagnosis: ViewBlock[] = [
+ {
+ ...lowConfidenceDiagnosis,
+ id: "diagnosis-run_a1b2c3d4e5",
+ revision: 1,
+ version: VIEW_BLOCK_VERSION,
+ summary: "Revision 1 — first guess, before the logs came back. Should not render.",
+ },
+ {
+ ...externalServiceDiagnosis,
+ id: "diagnosis-run_a1b2c3d4e5",
+ revision: 2,
+ version: VIEW_BLOCK_VERSION,
+ summary: "Revision 2 — narrowed to the payload, still unconfirmed. Should not render.",
+ },
+ {
+ ...fullDiagnosis,
+ id: "diagnosis-run_a1b2c3d4e5",
+ revision: 3,
+ version: VIEW_BLOCK_VERSION,
+ summary:
+ "Revision 3 — the only card that should render: processOrder threw on an order with no line items.",
+ },
+];
+
+const offerActionsBlock: ViewBlock = {
+ type: "actions",
+ id: "actions-offer",
+ revision: 0,
+ version: VIEW_BLOCK_VERSION,
+ actions: [
+ {
+ label: "Set up a watch",
+ intent: {
+ kind: "watch",
+ spec: {
+ kind: "error_recurrence",
+ fingerprint: "a1b2c3",
+ checkEveryMinutes: 15,
+ maxHours: 6,
+ note: "the TypeError in send-order-receipt",
+ },
+ },
+ },
+ {
+ label: "See its failed runs",
+ intent: { kind: "navigate", target: "trigger://proj_abc/env_abc/runs" },
+ },
+ ],
+};
+
+const DIAGNOSIS_CATEGORIES: DiagnosisBlock["category"][] = [
+ "user_code_error",
+ "configuration",
+ "dependency",
+ "timeout",
+ "out_of_memory",
+ "rate_limit",
+ "external_service",
+ "infrastructure",
+ "cancellation",
+ "unknown",
+];
+
+const CONFIDENCES: DiagnosisBlock["confidence"][] = ["high", "medium", "low"];
+
+const badgeMatrixBlocks: DiagnosisBlock[] = DIAGNOSIS_CATEGORIES.map((category, i) => ({
+ ...demoFixtures.demoDiagnosisBlockFirstPass,
+ category,
+ confidence: CONFIDENCES[i % CONFIDENCES.length]!,
+ evidence: [],
+ nextSteps: [],
+ actions: undefined,
+ impact: undefined,
+}));
+
+function EmptyChartCard() {
+ return (
+
+
+ {demoFixtures.demoChart.title}
+
+
+
+
+
+ );
+}
+
+/** The card primitive on its own: both body densities, and a card with no header. */
+function CardChrome({ density, header }: { density?: AgentCardDensity; header?: boolean }) {
+ return (
+
+ {header ? (
+
+ Card header
+
+
+ ) : null}
+
+
+ The card owns its border, surface and insets; the transcript owns where it sits.
+
+
+ A second section, so the body's density is visible as the gap between them.
+
+
+
+ );
+}
+
+const STATES: Record = {
+ "view-blocks-revisions": ,
+ "view-blocks-mixed": (
+
+ ),
+ "view-blocks-actions-offer": ,
+
+ "card-compact": ,
+ "card-roomy": ,
+ "card-headerless": ,
+
+ "diagnosis-full-high": ,
+ "diagnosis-low-minimal": ,
+ "diagnosis-badge-matrix": (
+
+ {badgeMatrixBlocks.map((block, i) => (
+
+
+
+ ))}
+
+ ),
+
+ "chart-with-actions": (
+
+ ),
+ "chart-empty": ,
+};
+
+export default function Story() {
+ return ;
+}
diff --git a/apps/webapp/app/routes/storybook.agent-watch/route.tsx b/apps/webapp/app/routes/storybook.agent-watch/route.tsx
new file mode 100644
index 00000000000..8dec4e5a288
--- /dev/null
+++ b/apps/webapp/app/routes/storybook.agent-watch/route.tsx
@@ -0,0 +1,143 @@
+import { VIEW_BLOCK_VERSION } from "@internal/dashboard-agent-contracts";
+import {
+ watchDraftFor,
+ withFollowUp,
+ withThreshold,
+ withVariant,
+} from "~/components/dashboard-agent/watch-card";
+import { WatchCard } from "~/components/dashboard-agent/WatchCard";
+import { watchConfirmationBlockBody, watchOneShotBlockBody } from "~/presenters/v3/dashboardAgent";
+import {
+ errorWatchRecommendation,
+ queueWatchRecommendation,
+ runWatchRecommendation,
+} from "~/components/dashboard-agent/watch-recommendations";
+import { WatchResultBlock } from "~/components/dashboard-agent/WatchResultBlock";
+import { watchWakeToastTitle, type WatchWake } from "~/components/dashboard-agent/WatchWakeToast";
+import { cn } from "~/utils/cn";
+import { GalleryPage, noop, PANEL_FRAME } from "../storybook.agent-ui/gallery";
+
+const queueWatchDraft = watchDraftFor(queueWatchRecommendation("email-sends"));
+
+const runWatchDraft = withFollowUp(watchDraftFor(runWatchRecommendation("run_a1b2c3d4e5")), {
+ investigateOnAttention: true,
+});
+
+const invalidThresholdDraft = withThreshold(
+ withVariant(queueWatchDraft, "queue_depth_above"),
+ Number.NaN
+);
+
+const queueBelowDraft = withThreshold(withVariant(queueWatchDraft, "queue_depth_below"), 100);
+const queueStalledDraft = withVariant(queueWatchDraft, "queue_stalled");
+
+const WATCH_BLOCK_ENVELOPE = {
+ id: "watch:watch_demo",
+ revision: 0,
+ version: VIEW_BLOCK_VERSION,
+} as const;
+
+const watchConfirmationBlock = {
+ ...watchConfirmationBlockBody({
+ spec: queueWatchRecommendation("email-sends"),
+ watchId: "watch_demo",
+ followUp: { investigateOnAttention: true, notifyExternally: true },
+ }),
+ ...WATCH_BLOCK_ENVELOPE,
+};
+
+const watchSatisfiedBlock = {
+ ...watchOneShotBlockBody({
+ spec: runWatchRecommendation("run_a1b2c3d4e5"),
+ result: "satisfied",
+ }),
+ ...WATCH_BLOCK_ENVELOPE,
+};
+
+const toastWakes: WatchWake[] = [
+ {
+ watchId: "watch_queue",
+ chatId: "chat_demo",
+ outcome: "fired",
+ note: "tell me when the email-sends backlog clears",
+ kind: "backlog_drain",
+ identity: "backlog_drain:email-sends",
+ resolution: "condition_met",
+ observedOutcome: { kind: "backlog_drain", verified: true, depth: 0 },
+ },
+ {
+ watchId: "watch_run_failed",
+ chatId: "chat_demo",
+ outcome: "fired",
+ note: "ping me when the nightly backfill finishes",
+ kind: "run_finished",
+ identity: "run_finished:run_a1b2c3d4e5",
+ resolution: "condition_met",
+ observedOutcome: {
+ kind: "run_finished",
+ verified: true,
+ finalStatus: "COMPLETED_WITH_ERRORS",
+ durationMs: 812_000,
+ },
+ },
+];
+
+function WakeToastHeadlines({ wakes }: { wakes: WatchWake[] }) {
+ return (
+
+ {wakes.map((wake) => (
+
+
{wake.kind}
+
{watchWakeToastTitle(wake)}
+
{wake.note}
+
+ ))}
+
+ );
+}
+
+const STATES: Record = {
+ "watch-card-compact": (
+
+ ),
+ "watch-card-expanded": (
+
+ ),
+ "watch-card-validation-error": (
+
+ ),
+ "watch-card-pending": (
+
+ ),
+ "watch-card-queue-below": (
+
+ ),
+ "watch-card-queue-stalled": (
+
+ ),
+ "watch-card-confirmation": ,
+ "watch-card-one-shot-satisfied": ,
+ "watch-card-toast-headline": ,
+};
+
+export default function Story() {
+ return ;
+}
diff --git a/apps/webapp/app/routes/storybook.toast/route.tsx b/apps/webapp/app/routes/storybook.toast/route.tsx
index e5daa0dd828..fffc53621b7 100644
--- a/apps/webapp/app/routes/storybook.toast/route.tsx
+++ b/apps/webapp/app/routes/storybook.toast/route.tsx
@@ -1,4 +1,4 @@
-import { Toaster, toast } from "sonner";
+import { toast } from "sonner";
import { Button } from "~/components/primitives/Buttons";
import { ToastUI } from "~/components/primitives/Toast";
@@ -17,6 +17,18 @@ export default function Story() {
message="This is a long error message that wraps over multiple lines so we can test the UI."
t="-"
/>
+
+
+ Open chat
+
+ }
+ />
Trigger error toast
-
-
+
+ toast.custom(
+ (t) => (
+ toast.dismiss(t as string)}
+ >
+ 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",