);
}
-export default function Story() {
+function PromptsHarness({
+ context,
+ promoted,
+ dismissedIds = [],
+}: {
+ context: AgentPageContext;
+ promoted?: SuggestedPrompt;
+ dismissedIds?: string[];
+}) {
+ const signals =
+ context.signals.length > 0 ? context.signals.map((s) => s.kind).join(", ") : "no signals";
return (
-
-
-
Dashboard agent UI
-
- Blocks the dashboard agent renders via its render_view tool, shown through the same
- ViewBlocks registry the chat panel uses. The catalog has the diagnosis (failure) card,
- shown here, and a chart block that runs a TRQL query live (only renders inside a
- project/env, so it's not shown here). Run links resolve inside a project; here they render
- as plain text.
-
+
+
+ {context.page.kind} — {signals}
+
+
+
+
+ );
+}
-
-
-
-
-
+function HeroHarness({
+ context,
+ promoted,
+ fullscreen = false,
+ withComposer = true,
+}: {
+ context: AgentPageContext;
+ promoted?: SuggestedPrompt;
+ fullscreen?: boolean;
+ withComposer?: boolean;
+}) {
+ const [input, setInput] = useState("");
+ return (
+
+
+
+ }
+ />
+ ) : undefined
+ }
+ />
+
+
+ );
+}
+
+function LiveInvestigationHarness() {
+ const messages: UIMessage[] = [
+ demoFixtures.userMessage("live-inv-q", "Why did this run fail?"),
+ demoFixtures.assistantMessage("live-inv", [
+ demoFixtures.renderViewPart(
+ [investigationBlock(demoInvestigations.streamingRev1)],
+ "render-live-investigation"
+ ),
+ ]),
+ ];
+ return (
+
+
+
+ );
+}
+
+const promotedPrompt: SuggestedPrompt = {
+ id: "sp:promo-storybook",
+ label: "Try the new health report",
+ prompt: "Give me a health report for this environment.",
+ source: "promoted",
+};
+
+// Pinned, not resolved: the fixture's signal has a fixed timestamp, so a live resolve would
+// dismiss a different chip once that timestamp aged out of the freshness window.
+const dismissedPromptIds = demoFixtures.demoDismissedPromptIds;
+
+function toWatchChip(watch: (typeof demoWatches.row)[number]): WatchChip {
+ return {
+ id: watch.id,
+ identity: watch.identity,
+ status: watch.status,
+ kind: watch.spec.kind,
+ note: watch.spec.note,
+ checkEveryMinutes: watch.spec.checkEveryMinutes,
+ expiresAt: watch.expiresAt,
+ };
+}
+
+function wakeMessage(watchId: string, outcome: "fired" | "expired", text: string): UIMessage {
+ return {
+ id: `wake:watch:${watchId}:${outcome}`,
+ role: "assistant",
+ parts: [{ type: "text", text }],
+ };
+}
+
+const wakeWatches: WakeWatch[] = [
+ {
+ id: "watch_health",
+ kind: "health_recovery",
+ note: "prod health back to normal",
+ identity: "health_recovery:health",
+ resolution: "condition_met",
+ observedOutcome: { kind: "health_recovery", verified: true, severity: "ok" },
+ },
+ {
+ id: "watch_error",
+ kind: "error_recurrence",
+ note: "tell me if that TypeError comes back",
+ identity: "error_recurrence:a1b2c3d4e5f6",
+ resolution: "condition_met",
+ observedOutcome: { kind: "error_recurrence", verified: true, countSince: 6 },
+ },
+ {
+ id: "watch_queue_gone",
+ kind: "backlog_drain",
+ note: "tell me when the email-sends backlog clears",
+ identity: "backlog_drain:email-sends",
+ resolution: "condition_impossible",
+ observedOutcome: { kind: "backlog_drain", verified: true, depth: null },
+ },
+ {
+ id: "watch_unverified",
+ kind: "backlog_drain",
+ note: "tell me when the email-sends backlog clears",
+ identity: "backlog_drain:email-sends",
+ resolution: "window_completed",
+ observedOutcome: { kind: "backlog_drain", verified: false, depth: null },
+ },
+];
+
+function WakeHarness({ message, watches }: { message: UIMessage; watches?: WakeWatch[] }) {
+ return (
+
+
);
}
+
+const STATES: Record
= {
+ "hero-panel": ,
+ "hero-panel-contextual": ,
+ "hero-fullscreen": ,
+ "hero-in-chat": ,
+
+ "prompts-default": ,
+ "prompts-contextual-fresh-failure": ,
+ "prompts-promoted": (
+
+ ),
+ "prompts-dismissed": (
+
+ ),
+
+ "messages-streaming-text": ,
+ "messages-reasoning": ,
+ "messages-tool-in-flight": ,
+ "messages-tool-pending-pills": ,
+ "messages-error-retry": ,
+ "messages-render-view": ,
+ "messages-investigation-live": ,
+ "messages-docs-sources": ,
+
+ "intent-navigate-filtered-runs": (
+
+ ),
+ "intent-watch": ,
+ "intent-rejected-propose-fix": (
+
+ ),
+
+ "wake-positive": (
+
+ ),
+ "wake-attention": (
+
+ ),
+ "wake-neutral-impossible": (
+
+ ),
+ "wake-unverified": (
+
+ ),
+
+ "watches-live": ,
+
+ "banner-prod": (
+
+ ),
+ "banner-preview-long": (
+
+ ),
+};
+
+export default function Story() {
+ return ;
+}
diff --git a/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx b/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx
new file mode 100644
index 00000000000..cf6f5d84611
--- /dev/null
+++ b/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx
@@ -0,0 +1,124 @@
+import type { DiagnosisBlock } from "@internal/dashboard-agent";
+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 {
+ fullDiagnosis,
+ lowConfidenceDiagnosis,
+ offerActionsBlock,
+ revisedDiagnosisBlocks,
+} from "../storybook.agent-ui/fixtures";
+import { GalleryPage, noop } from "../storybook.agent-ui/gallery";
+
+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..9d7d62dc6a4
--- /dev/null
+++ b/apps/webapp/app/routes/storybook.agent-watch/route.tsx
@@ -0,0 +1,124 @@
+import {
+ watchDraftFor,
+ withFollowUp,
+ withThreshold,
+ withVariant,
+} from "~/components/dashboard-agent/watch-card";
+import { WatchCard } from "~/components/dashboard-agent/WatchCard";
+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 {
+ watchConfirmationBlock,
+ watchDegradedConfirmationBlock,
+ watchSatisfiedBlock,
+} from "../storybook.agent-ui/fixtures";
+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 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-confirmation-degraded": ,
+ "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",
diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts
index d9f2351d819..9b8e0e169a1 100644
--- a/apps/webapp/vitest.config.ts
+++ b/apps/webapp/vitest.config.ts
@@ -20,6 +20,7 @@ export default defineConfig({
"app/utils/**/*.test.ts",
"app/components/dashboard-agent/**/*.test.ts",
"app/components/queues/**/*.test.ts",
+ "app/routes/storybook.agent-ui/*.test.ts",
],
// *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts.
// *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts