diff --git a/.changeset/report-health.md b/.changeset/report-health.md
new file mode 100644
index 00000000000..92dc9d6588f
--- /dev/null
+++ b/.changeset/report-health.md
@@ -0,0 +1,8 @@
+---
+"@trigger.dev/core": patch
+"trigger.dev": patch
+---
+
+Add the `get_report` MCP tool, a `trigger report` CLI command, and `GET /api/v1/reports/:key`, starting with the `health` report: a deterministic verdict on whether work is flowing, whether the runs that start are healthy, and whether telemetry is fresh — rendered as text with unicode sparklines (coloured in a terminal). Also adds a `report` MCP prompt, surfaced as a slash command in hosts that support prompts.
+
+Also: `trigger mcp` now always starts the server — the install wizard is gated behind `trigger mcp --install`. A TTY previously launched the wizard, so MCP hosts spawning the server over a PTY timed out.
diff --git a/.server-changes/agent-detail-metrics-layout.md b/.server-changes/agent-detail-metrics-layout.md
new file mode 100644
index 00000000000..0af763ff3f5
--- /dev/null
+++ b/.server-changes/agent-detail-metrics-layout.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: improvement
+---
+
+The agent detail page now matches the shared metrics-page layout: the time filter and pagination sit in a filter row at the top, the activity charts form a tile row beneath it, and the tabs and table flow below in a single page scroll, with the agent config panel as a resizable sidebar on the right.
diff --git a/.server-changes/dashboard-agent-first-turn-error.md b/.server-changes/dashboard-agent-first-turn-error.md
new file mode 100644
index 00000000000..c3dea3a8f8d
--- /dev/null
+++ b/.server-changes/dashboard-agent-first-turn-error.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: fix
+---
+
+When the assistant fails to start its very first reply, the chat now shows an error you can retry instead of sitting empty.
diff --git a/.server-changes/dashboard-agent-investigate.md b/.server-changes/dashboard-agent-investigate.md
new file mode 100644
index 00000000000..5fa5a450385
--- /dev/null
+++ b/.server-changes/dashboard-agent-investigate.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+The dashboard agent can now investigate failures for you: click Investigate on a failed run, an error, or a backed-up queue and it gathers the evidence, tests a few hypotheses, and answers with what happened and how to fix it — every claim linked to the runs, errors, and deploys behind it. Available to organizations with the dashboard agent enabled.
diff --git a/.server-changes/dashboard-agent-watch-alerts.md b/.server-changes/dashboard-agent-watch-alerts.md
new file mode 100644
index 00000000000..29473e5722c
--- /dev/null
+++ b/.server-changes/dashboard-agent-watch-alerts.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+Watches you set up with the dashboard agent can now alert you by email, Slack, or webhook when they fire. Pick the new "Dashboard agent watches" type on the Alerts page, and turn it off again from any alert email.
diff --git a/.server-changes/dashboard-agent-watches.md b/.server-changes/dashboard-agent-watches.md
new file mode 100644
index 00000000000..2374ece89af
--- /dev/null
+++ b/.server-changes/dashboard-agent-watches.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+Ask the dashboard agent to tell you when something happens — a run starting or finishing, a backlog clearing, an error coming back, an environment recovering — and it messages you in the chat once it does. Each chat can wait on up to three things at a time, for up to 24 hours.
diff --git a/.server-changes/paginate-concurrency-keys-table.md b/.server-changes/paginate-concurrency-keys-table.md
new file mode 100644
index 00000000000..da13bfe1695
--- /dev/null
+++ b/.server-changes/paginate-concurrency-keys-table.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: improvement
+---
+
+The concurrency keys table on a queue's page is now paginated, so queues with thousands of keys can page through all of them instead of only showing the top 50.
diff --git a/.server-changes/queue-metrics-api.md b/.server-changes/queue-metrics-api.md
new file mode 100644
index 00000000000..72ac98360e3
--- /dev/null
+++ b/.server-changes/queue-metrics-api.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+You can now read a single queue's wait times, peak depth, throughput, and how often it was throttled over a time window from the API.
diff --git a/.server-changes/queue-metrics-dashboard.md b/.server-changes/queue-metrics-dashboard.md
new file mode 100644
index 00000000000..74245fefe6c
--- /dev/null
+++ b/.server-changes/queue-metrics-dashboard.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+New Queue metrics & health dashboard (per-org opt-in): per-queue depth, throughput, concurrency, throttling and scheduling-delay charts, a per-queue detail view, and live queue stats. Queue concurrency limits set above the environment limit are now rejected instead of being silently capped.
diff --git a/.server-changes/remove-header-docs-buttons.md b/.server-changes/remove-header-docs-buttons.md
new file mode 100644
index 00000000000..74275b0f93c
--- /dev/null
+++ b/.server-changes/remove-header-docs-buttons.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: improvement
+---
+
+The Docs button has been removed from page headers to reduce clutter. Documentation links remain available in context where they're most useful.
diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore
index 595ab180e15..3595adea8cb 100644
--- a/apps/webapp/.gitignore
+++ b/apps/webapp/.gitignore
@@ -7,6 +7,9 @@ node_modules
/cypress/screenshots
/cypress/videos
+# Output of `pnpm run agent-ui:screenshots`
+/screenshots
+
/app/styles/tailwind.css
# Ensure the .env symlink is not removed by accident
@@ -20,4 +23,6 @@ storybook-static
/prisma/seed.js
/prisma/populate.js
-.memory-snapshots
\ No newline at end of file
+.memory-snapshots
+# Heartbeat story mode for seed-agent-examples (degraded | calm)
+.agent-examples-heartbeat-mode
diff --git a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx
index 6e082231eb6..86b2eed9499 100644
--- a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx
+++ b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx
@@ -7,6 +7,7 @@ import { Spinner } from "~/components/primitives/Spinner";
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
+import { cn } from "~/utils/cn";
// Render an agent "chart" block by running its TRQL query through the dashboard's
// own /resources/metric endpoint (session-authed, returns rows + real column
@@ -25,6 +26,21 @@ type MetricResponse =
};
};
+// The chart block's schema (`chartBlockBodySchema` in
+// @internal/dashboard-agent-contracts) carries only `period` for the time window
+// — no scope, no explicit from/to, no height. So these are fixed here rather
+// than plumbed from the block. If the schema grows those fields, read them off
+// `block` instead of using these.
+const CHART_SCOPE = "environment"; // the panel is always open in one environment
+const CHART_FROM = null; // `period` is the only window the agent can ask for
+const CHART_TO = null;
+const CHART_HEIGHT_CLASS = "h-64"; // fits the panel at its default width
+
+// Query errors come from ClickHouse via the metric endpoint and can carry SQL
+// and schema detail, so the panel shows a fixed message and the real one goes to
+// the console for whoever is debugging.
+const CHART_ERROR_MESSAGE = "This chart's query couldn't run.";
+
type ChartState =
| { status: "loading" }
| { status: "error"; error: string }
@@ -63,10 +79,10 @@ export function AgentChart({ block }: { block: ChartBlock }) {
organizationId,
projectId,
environmentId,
- scope: "environment",
+ scope: CHART_SCOPE,
period: block.period ?? null,
- from: null,
- to: null,
+ from: CHART_FROM,
+ to: CHART_TO,
}),
signal: controller.signal,
})
@@ -74,7 +90,8 @@ export function AgentChart({ block }: { block: ChartBlock }) {
.then((data) => {
if (controller.signal.aborted) return;
if (!data.success) {
- setState({ status: "error", error: data.error });
+ console.error("Dashboard agent chart query failed:", data.error);
+ setState({ status: "error", error: CHART_ERROR_MESSAGE });
} else {
setState({
status: "ready",
@@ -86,7 +103,8 @@ export function AgentChart({ block }: { block: ChartBlock }) {
})
.catch((err) => {
if (controller.signal.aborted) return;
- setState({ status: "error", error: err?.message ?? "The query failed to run." });
+ console.error("Dashboard agent chart request failed:", err);
+ setState({ status: "error", error: CHART_ERROR_MESSAGE });
});
return () => controller.abort();
}, [block.query, block.period, organizationId, projectId, environmentId]);
@@ -109,7 +127,7 @@ export function AgentChart({ block }: { block: ChartBlock }) {
{block.title}
) : null}
-
+
{state.status === "loading" ? (
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
index 2796c8516df..7659cbcb22c 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
@@ -1,11 +1,25 @@
-import { useState } from "react";
+import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
+import { useEnvironment } from "~/hooks/useEnvironment";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
import { DashboardAgentPanel } from "./DashboardAgentPanel";
import { DashboardAgentProvider } from "./dashboardAgentLauncher";
+import {
+ showWatchWakesSummaryToast,
+ showWatchWakeToast,
+ WAKE_TOAST_MAX_INDIVIDUAL,
+ type WatchWake,
+} from "./WatchWakeToast";
+
+// How often the closed panel asks whether a watch woke a chat. A wake is worth
+// noticing within a minute, and the count is one indexed query.
+const UNREAD_POLL_INTERVAL_MS = 60_000;
/**
* Mounts the dashboard agent in the env layout. Renders the page content
@@ -22,18 +36,135 @@ import { DashboardAgentProvider } from "./dashboardAgentLauncher";
export function DashboardAgent({
children,
hasAccess = false,
+ promotedPrompt,
}: {
children: React.ReactNode;
hasAccess?: boolean;
+ // The product-controlled promoted prompt chip, from the feature flag.
+ promotedPrompt?: SuggestedPrompt;
}) {
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+ const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
+
const [open, setOpen] = useState(false);
+ const [unreadWakes, setUnreadWakes] = useState(0);
+ // Wakes already toasted this session. Session-scoped on purpose: a wake that
+ // arrived overnight deserves the toast on the first poll after a reload, but a
+ // wake the user has already been shown (and maybe dismissed) must not come
+ // back every 60s while the chat stays unread.
+ const toastedWakes = useRef(new Set());
+ // A request from `openWith`, handed to the panel. `seq` makes repeat requests
+ // with the same text distinct, so the panel can tell them apart.
+ const [requestedMessage, setRequestedMessage] = useState<
+ { text: string; seq: number } | undefined
+ >(undefined);
+ // A specific chat to open, from a wake toast. `seq` so the same chat can be
+ // asked for twice (a second wake in a chat the user has already left).
+ const [openChatRequest, setOpenChatRequest] = useState<
+ { chatId: string; seq: number } | undefined
+ >(undefined);
+
+ // Closing drops any pending request, so reopening the panel later doesn't
+ // replay text the user has moved on from.
+ const setPanelOpen = useCallback((next: boolean) => {
+ setOpen(next);
+ // Closing drops both pending requests: the panel unmounts, so a stale one
+ // would re-apply on the next open instead of restoring the last chat.
+ if (!next) {
+ setRequestedMessage(undefined);
+ setOpenChatRequest(undefined);
+ }
+ }, []);
+
+ // Open the panel on the chat a wake happened in. Without the chat id the panel
+ // would just restore whatever it had open last, which is rarely the one the
+ // toast is about.
+ const openChat = useCallback((chatId: string) => {
+ setOpen(true);
+ setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
+ }, []);
+
+ const openWith = useCallback((text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed) return;
+ setOpen(true);
+ setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
+ }, []);
+
+ // The dot's poll, and the toast's. Runs only while the panel is CLOSED — an
+ // open panel shows the wake in the transcript, so polling then would only race
+ // the read marker. Both the interval and the on-close refresh come from this
+ // effect re-running on `open`.
+ useEffect(() => {
+ if (!hasAccess || open) return;
+
+ let cancelled = false;
+ const load = async () => {
+ try {
+ const res = await fetch(`${actionPath}?unread=1`);
+ if (!res.ok) return;
+ const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] };
+ if (cancelled) return;
+ setUnreadWakes(data.unreadWakes ?? 0);
+
+ const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId));
+ for (const wake of fresh) toastedWakes.current.add(wake.watchId);
+
+ // A burst gets one summary toast: a stack of persistent toasts is a wall,
+ // not a notification.
+ if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) {
+ showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true));
+ } else {
+ // Oldest first, so the newest wake ends up nearest the user.
+ for (const wake of [...fresh].reverse()) {
+ showWatchWakeToast(wake, openChat);
+ }
+ }
+ } catch {
+ // Offline or a hiccup — leave the dot as it is and try again next tick.
+ }
+ };
+
+ void load();
+ const interval = window.setInterval(load, UNREAD_POLL_INTERVAL_MS);
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ };
+ }, [hasAccess, open, actionPath, setPanelOpen, openChat]);
+
+ // A chat the user is now looking at has no unread wakes. Zeroes the dot right
+ // away (the poll restores the truth on close if another chat still has one) and
+ // persists the read marker for the chat that's actually visible.
+ const markChatRead = useCallback(
+ async (chatId: string) => {
+ setUnreadWakes(0);
+ const body = new FormData();
+ body.set("intent", "read");
+ body.set("chatId", chatId);
+ try {
+ await fetch(actionPath, { method: "POST", body });
+ } catch {
+ // Not worth surfacing: the marker is caught up the next time the chat is
+ // opened.
+ }
+ },
+ [actionPath]
+ );
+
+ const context = useMemo(
+ () => ({ open, setOpen: setPanelOpen, openWith, unreadWakes }),
+ [open, setPanelOpen, openWith, unreadWakes]
+ );
if (!hasAccess) {
return
{children}
;
}
return (
-
+
{open ? (
- setOpen(false)} />
+ setPanelOpen(false)}
+ requestedMessage={requestedMessage}
+ openChatRequest={openChatRequest}
+ promotedPrompt={promotedPrompt}
+ onChatRead={markChatRead}
+ />
) : (
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
index e6662ca8494..4f1572d4241 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
@@ -1,12 +1,41 @@
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "@ai-sdk/react";
import type { dashboardAgent } from "@internal/dashboard-agent";
+import type { AgentIntent, SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
+import { useNavigate } from "@remix-run/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useCallback, useEffect, useRef, useState } from "react";
+import { useToast } from "~/components/primitives/Toast";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
-import { DashboardAgentMessages } from "./DashboardAgentMessages";
+import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
+import { createTranscriptOrder, orderTranscript } from "./message-order";
+import { appendRunFilters, pendingNavigateIntents } from "./navigate-target";
+import type { AgentPageContext } from "./page-context-types";
+import { WatchChips, type WatchChip } from "./WatchChips";
+
+/**
+ * The message a card's watch button sends on the user's behalf. Written the way
+ * the user would ask, so the transcript reads as a request the agent then
+ * confirms (via schedule_watch), not as UI state that changed silently.
+ */
+function watchRequestText(spec: WatchSpec): string {
+ const note = "note" in spec && spec.note ? spec.note.trim() : "";
+ if (note) return `Watch this for me — tell me when ${note}.`;
+ switch (spec.kind) {
+ case "backlog_drain":
+ return `Watch this for me — tell me when the ${spec.queue} backlog drains.`;
+ case "run_start":
+ return `Watch this for me — tell me when run ${spec.runId} starts.`;
+ case "run_finished":
+ return `Watch this for me — tell me when run ${spec.runId} finishes.`;
+ case "error_recurrence":
+ return `Watch this for me — ping me if error ${spec.fingerprint} comes back.`;
+ case "health_recovery":
+ return "Watch this for me — tell me when health is back to normal.";
+ }
+}
// The persisted session for a chat: the session-scoped token plus the stream
// cursor. Resuming with `lastEventId` is what stops the agent's `.out` stream
@@ -23,6 +52,10 @@ export type DashboardAgentClientData = {
projectId?: string;
environmentId?: string;
currentPage?: string;
+ // What page the user is on, as facts rather than a path. Sent on create and
+ // on every turn, so the agent sees where the user is now — not where they
+ // were when the chat started.
+ pageContext?: AgentPageContext;
};
/**
@@ -44,7 +77,12 @@ export function DashboardAgentChat({
currentPage,
pendingFirstMessage,
streaming,
+ prefill,
+ promotedPrompt,
+ watches,
+ onCancelWatch,
onTurnSettled,
+ onActivityChange,
}: {
chatId: string;
initialMessages: UIMessage[];
@@ -54,6 +92,8 @@ export function DashboardAgentChat({
actionPath: string;
projectSlug: string;
environmentSlug: string;
+ // Human label for the current page, for the context banner. The path the agent
+ // sees travels separately, in `clientData.currentPage`.
currentPage: string;
// Cold start: send this first message through the transport once on mount to
// trigger the turn. Undefined for head-started and resumed chats.
@@ -62,9 +102,36 @@ export function DashboardAgentChat({
// streaming so the transport resumes `session.out` instead of treating it as
// a settled session with nothing to reconnect to.
streaming?: boolean;
+ // Text dropped into the composer from outside (the launcher's `openWith`).
+ // `seq` makes each request distinct so the same text can be sent twice.
+ prefill?: { text: string; seq: number };
+ // The product-controlled promoted chip, from the feature flag. Only used for
+ // the suggested prompts on an empty chat.
+ promotedPrompt?: SuggestedPrompt;
+ // This chat's active watches, from the panel's history load.
+ watches: WatchChip[];
+ onCancelWatch: (watchId: string) => void;
+ /** A watch was created — tell the panel to re-read the chips. */
onTurnSettled: () => void;
+ /**
+ * Whether a turn is in flight, for the History list's row marker. Only this
+ * component knows — the turn status is `useChat`'s, with nothing server-side
+ * to read it back from.
+ */
+ onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
}) {
const [input, setInput] = useState("");
+ const navigate = useNavigate();
+ const toast = useToast();
+
+ // Put requested text in the composer rather than sending it: a chat is already
+ // open, so the user gets to read and edit before it goes.
+ const prefilledSeq = useRef(undefined);
+ useEffect(() => {
+ if (!prefill || prefilledSeq.current === prefill.seq) return;
+ prefilledSeq.current = prefill.seq;
+ setInput(prefill.text);
+ }, [prefill]);
const transport = useTriggerChatTransport({
task: "dashboard-agent",
@@ -119,11 +186,12 @@ export function DashboardAgentChat({
});
const {
- messages,
+ messages: rawMessages,
sendMessage,
status,
stop: aiStop,
error,
+ clearError,
} = useChat({
id: chatId,
messages: initialMessages,
@@ -133,8 +201,19 @@ export function DashboardAgentChat({
resume: !!session && !pendingFirstMessage,
});
+ // The transcript in stable order. The store's copy is the base; live arrivals
+ // go after it, and a turn the stream replays goes back into its own slot — so
+ // a message sent right after a remount can't land between older turns. See
+ // `message-order.ts`.
+ const orderRef = useRef(createTranscriptOrder(initialMessages));
+ const messages = orderTranscript(rawMessages, orderRef.current);
+
const isStreaming = status === "streaming";
- const isThinking = status === "submitted";
+ // A turn is in flight from submit until it settles. Deriving the indicator
+ // from status (rather than from what the last part happens to be) keeps it up
+ // through long tool calls, where the agent is busy but silent.
+ const activity: TurnActivity | null =
+ status === "submitted" ? "thinking" : status === "streaming" ? "working" : null;
// Cold start: trigger the first turn by sending the pending message once.
const sentFirst = useRef(false);
@@ -155,6 +234,87 @@ export function DashboardAgentChat({
[isStreaming, sendMessage]
);
+ // Re-send the last thing the user asked. The failed turn produced nothing, so
+ // sending the same text again is the whole retry — no server-side state to
+ // unwind.
+ const retry = useCallback(() => {
+ const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
+ const text = lastUserMessage?.parts
+ ?.filter((p): p is { type: "text"; text: string } => p.type === "text")
+ .map((p) => p.text)
+ .join("\n")
+ .trim();
+ clearError();
+ if (text) void sendMessage({ text });
+ }, [messages, sendMessage, clearError]);
+
+ // Take the user where a `navigate` intent points. The target is a `trigger://`
+ // URI, so the path comes from the server (the route's `resolve` intent, which
+ // owns the environment scope the resolver needs); the intent's runs-list
+ // filters are applied on top. Same-origin, so this is a client-side navigation
+ // — the panel lives in the env layout and survives it.
+ const goTo = useCallback(
+ async (intent: Extract) => {
+ const body = new FormData();
+ body.set("intent", "resolve");
+ body.set("uri", intent.target);
+ try {
+ const res = await fetch(actionPath, { method: "POST", body });
+ const data = (await res.json()) as { path?: string };
+ if (!res.ok || !data.path) throw new Error(`Resolve failed (${res.status})`);
+ navigate(appendRunFilters(data.path, intent.filters));
+ } catch (error) {
+ console.error("Dashboard agent: failed to resolve a navigate target", error);
+ toast.error("Couldn't open that page.");
+ }
+ },
+ [actionPath, navigate, toast]
+ );
+
+ // What a card's action does. An `ask` goes back into the conversation as the
+ // user's own question — and so does a `watch`: the click becomes a visible
+ // request ("Watch this for me…") and the agent answers it with schedule_watch,
+ // confirming in its own words and offering an email alert when none is set up.
+ // A silent POST would be cheaper, but a watch the transcript never mentions
+ // reads as nothing having happened.
+ //
+ // `propose_fix` is reserved and must never be executed.
+ const handleIntent = useCallback(
+ (intent: AgentIntent) => {
+ switch (intent.kind) {
+ case "ask":
+ submit(intent.prompt);
+ return;
+ case "watch":
+ submit(watchRequestText(intent.spec));
+ return;
+ case "navigate":
+ void goTo(intent);
+ return;
+ default:
+ console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`);
+ }
+ },
+ [submit, goTo]
+ );
+
+ // The `navigate_to` tool answers with an intent and the agent then narrates it
+ // in the past tense ("you're now on…"), so the panel has to actually move.
+ // Seeded with the loaded transcript before the first render, so opening a chat
+ // whose history contains a navigation never navigates on replay — only calls
+ // that land while this chat is open are honoured, once each.
+ const navigatedRef = useRef | null>(null);
+ if (navigatedRef.current === null) {
+ navigatedRef.current = new Set();
+ pendingNavigateIntents(initialMessages, navigatedRef.current);
+ }
+ useEffect(() => {
+ const pending = pendingNavigateIntents(messages, navigatedRef.current!);
+ // Only the last one matters — the earlier destinations are already history.
+ const target = pending.at(-1);
+ if (target?.kind === "navigate") void goTo(target);
+ }, [messages, goTo]);
+
const stop = useCallback(() => {
transport.stopGeneration(chatId);
aiStop();
@@ -170,6 +330,13 @@ export function DashboardAgentChat({
prevStatus.current = status;
}, [status, onTurnSettled]);
+ // Report the turn's activity up, so the History list can mark this chat while
+ // it's working. Not cleared on unmount: opening History unmounts this chat but
+ // the turn carries on server-side, and it reports again when it remounts.
+ useEffect(() => {
+ onActivityChange?.(chatId, activity);
+ }, [chatId, activity, onActivityChange]);
+
return (
<>
- {messages.length === 0 ? (
-
+ {/* What this chat is watching, right under the banner: a watch outcome
+ arrives in the transcript unprompted, so the chips are what explain
+ where those messages will come from. */}
+ {/* Chips are an offer to cancel, so only live watches get one; the full
+ list still flows to the messages for the wake banner's tone. */}
+ watch.status === "active")}
+ onCancel={onCancelWatch}
+ />
+ {/* A cold-start chat mounts with no messages and a first message about to
+ be sent, so the prompts would flash for a frame before the transcript
+ replaced them. Gate on that pending send. */}
+ {messages.length === 0 && !pendingFirstMessage ? (
+
) : (
-
+
)}
submit(input)}
onStop={stop}
isStreaming={isStreaming}
+ focusKey={prefill?.seq}
/>
>
);
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
index 308a87ebab1..56147a8f11b 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
@@ -1,5 +1,5 @@
import { PaperAirplaneIcon, StopIcon } from "@heroicons/react/20/solid";
-import { useRef } from "react";
+import { useEffect, useRef } from "react";
import { Button } from "~/components/primitives/Buttons";
import { cn } from "~/utils/cn";
@@ -9,21 +9,37 @@ export function DashboardAgentComposer({
onSubmit,
onStop,
isStreaming,
+ focusKey,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
isStreaming: boolean;
+ // Bump to move focus back to the textarea — e.g. text was just prefilled from
+ // outside the panel. Focus also happens on mount (panel open, chat switch).
+ focusKey?: string | number;
}) {
const ref = useRef(null);
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ el.focus();
+ // Caret after any prefilled text, so typing continues the sentence.
+ el.setSelectionRange(el.value.length, el.value.length);
+ }, [focusKey]);
+
return (
+ {/* One text line tall at rest (matches the Send button height), grows
+ with content up to the cap. rows={1} + field-sizing-content do the
+ work; the old min-h forced a second line's worth of empty space. */}
);
}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
index 115710139e7..8d8624ce7fb 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
@@ -1,7 +1,9 @@
+import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { useCallback, useState } from "react";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
+import type { AgentPageContext } from "./page-context-types";
/**
* The new-chat "draft" state: suggested prompts + composer with no transport
@@ -15,11 +17,17 @@ export function DashboardAgentDraft({
projectSlug,
environmentSlug,
currentPage,
+ pageContext,
+ promotedPrompt,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
environmentSlug: string;
currentPage: string;
+ // What the user is looking at, so the suggested prompts can react to it.
+ pageContext?: AgentPageContext;
+ // The product-controlled promoted chip, from the feature flag.
+ promotedPrompt?: SuggestedPrompt;
}) {
const [input, setInput] = useState("");
@@ -40,7 +48,11 @@ export function DashboardAgentDraft({
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
-
+ void;
onToggleHistory: () => void;
onClose: () => void;
}) {
return (
-
- Chat
-
+
+
+ {title}
+
+
= {
+ thinking: "Agent is thinking",
+ investigating: "Investigation in progress",
+ watching: "Watch active",
+};
+
+/**
+ * `thinking` outranks the rest: a turn in flight is the thing that's about to
+ * change, an investigation or a watch just sits there.
+ */
+function chatProcess(chat: DashboardAgentChat, isThinking: boolean): ChatProcess | null {
+ if (isThinking) return "thinking";
+ if (chat.hasOpenInvestigation) return "investigating";
+ if (chat.hasActiveWatch) return "watching";
+ return null;
+}
+
+function ProcessIcon({ process }: { process: ChatProcess }) {
+ const label = PROCESS_LABELS[process];
+ // No custom tooltip: the row is a button, so a tooltip trigger here would nest
+ // one button in another. `title` says the same thing.
+ return (
+
+ {process === "investigating" ? (
+
+ ) : (
+ // Thinking and watching both spin — "something is going on here"; the
+ // hover title says which.
+
+ )}
+
+ );
+}
+
+/**
+ * Chats with an unread wake go to the top — a watch that fired is the reason to
+ * open the panel at all. Everything else keeps the server's order (pinned first,
+ * then most recent), so this is a stable sort on one key.
+ */
+function unreadFirst(chats: DashboardAgentChat[]): DashboardAgentChat[] {
+ return [...chats].sort(
+ (a, b) => Number(b.hasUnreadWake ?? false) - Number(a.hasUnreadWake ?? false)
+ );
+}
+
export function DashboardAgentHistory({
chats,
currentChatId,
+ thinkingChatId,
onSelect,
- onNewChat,
onDelete,
}: {
chats: DashboardAgentChat[];
currentChatId: string;
+ /**
+ * The chat with a turn in flight, if any. Client-side only — nothing marks a
+ * live turn server-side, so this is knowable for the open chat alone.
+ */
+ thinkingChatId?: string | null;
onSelect: (chatId: string) => void;
- onNewChat: () => void;
onDelete: (chatId: string) => void;
}) {
+ // Deleting a chat is irreversible, so it goes through a confirm step. Holding
+ // the whole chat lets the dialog name what's being deleted.
+ const [pendingDelete, setPendingDelete] = useState(null);
+
return (
-
-
- New chat
-
-
+ {/* New chat lives as the header icon button only — no duplicate row here. */}
{chats.length === 0 ? (
No previous chats yet.
) : (
-
- {chats.map((chat) => (
-
);
}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
index 1d02bf46621..9fdbcd12ab8 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
@@ -1,9 +1,61 @@
import type { UIMessage } from "@ai-sdk/react";
+import { ArrowPathIcon, BookOpenIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import type { AgentIntent } from "@internal/dashboard-agent-contracts";
+import { useNavigate } from "@remix-run/react";
import { memo } from "react";
-import { Spinner } from "~/components/primitives/Spinner";
-import { MessageBubble, renderPart } from "~/components/runs/v3/agent/AgentMessageView";
-import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { Callout } from "~/components/primitives/Callout";
+import { renderPart, toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
+import { sameOriginPath } from "./navigate-target";
+import { hasToolProgressLine, IN_FLIGHT_TOOL_STATES } from "./progress-line";
+import { useTranscriptAutoScroll } from "./useTranscriptAutoScroll";
+import {
+ ChatActionsRow,
+ ChatCardSlot,
+ ChatPendingTool,
+ ChatProgress,
+ ChatText,
+ ChatTranscript,
+ ChatTurn,
+ ChatWakeSlot,
+} from "./chat-layout";
+import { toolPendingLabel } from "./tool-labels";
+import { reportBlockFromToolPart } from "./report-block-adapter";
+import type { ResolvedUri } from "./ReportView";
import { ViewBlocks } from "./view-catalog";
+import { findWakeWatch, WakeBanner, wakeRefFromMessageId, type WakeWatch } from "./WakeBanner";
+
+// "thinking" — the turn is submitted but nothing has come back yet.
+// "working" — the turn is streaming: text, or (more often) tool calls, which can
+// run for a while with no visible output.
+export type TurnActivity = "thinking" | "working";
+
+const ACTIVITY_LABELS: Record = {
+ thinking: "Thinking…",
+ working: "Working…",
+};
+
+export type DashboardAgentMessagesProps = {
+ messages: UIMessage[];
+ // What the turn is doing right now, or null when nothing is in flight. A turn
+ // spends most of its time streaming tool calls, so the indicator has to stay
+ // up for the whole turn — not just the initial submit.
+ activity: TurnActivity | null;
+ error?: Error;
+ onRetry?: () => void;
+ onDismissError?: () => void;
+ /** Where a card's actions go. Threaded down to the view catalog. */
+ onIntent?: (intent: AgentIntent) => void;
+ /** Host resolver for `trigger://` URIs a card cites. */
+ resolveUri?: (uri: string) => ResolvedUri | null;
+ /**
+ * The chat's watches, when the host has them. A wake message names the watch
+ * it came from, so this is what lets its banner say *what* was being watched
+ * and colour the outcome by kind. Without it a wake still gets a banner, in
+ * kind-agnostic wording.
+ */
+ watches?: WakeWatch[];
+};
// The shared MessageBubble renders `step-start` parts as a dashed "step"
// separator — useful in the run inspector / playground, just noise in this
@@ -23,62 +75,275 @@ function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } |
return Array.isArray(p.output?.blocks) ? { blocks: p.output!.blocks! } : null;
}
-// Renders one message. Assistant messages that include a completed render_view
-// part get the catalog cards (plus the gather tool rows / lead-in text for
-// transparency); everything else uses the shared MessageBubble unchanged, so
-// its streaming memoization is preserved for the common case.
-const DashboardAgentMessageBubble = memo(function DashboardAgentMessageBubble({
+/**
+ * The blocks one part contributes, or null when it isn't a card at all.
+ *
+ * Two sources, one renderer:
+ * - `render_view` — blocks the model composed.
+ * - `get_report` — a snapshot block the host builds from the tool's own output,
+ * so the card shows the numbers the model was grounded on rather than numbers
+ * it retyped.
+ *
+ * Both replace the generic tool row: a rendered card already says everything the
+ * raw JSON would. A `get_report` part that can't be adapted returns null and
+ * falls through — to the pending pill while it is still streaming, to the tool row
+ * once it has failed, so the failure stays visible.
+ */
+function blocksFor(part: UIMessage["parts"][number]): unknown[] | null {
+ const spec = viewSpecFor(part);
+ if (spec) return spec.blocks;
+ const report = reportBlockFromToolPart(part);
+ return report ? [report] : null;
+}
+
+// #region chat-layout transcript
+// Everything from here down composes via ./chat-layout only — see rule 1 there.
+// `chat-layout.test.ts` fails if a spacing utility class appears in this region.
+
+/**
+ * One assistant part in the chat panel.
+ *
+ * Everything the panel styles itself is handled here; the rest falls through to
+ * the shared `renderPart` so agent output still looks the same across the app.
+ * The differences: text is always the rendered markdown (no raw toggle) at the
+ * dashboard's default size, and an in-flight tool call is a pending pill rather
+ * than a tool row streaming its input JSON — the input is the agent's business,
+ * and watching it arrive character by character only to have it replaced by a
+ * card is noise. Once the call lands, the part renders exactly as it always did.
+ * Citations are handled a level up, where a run of them can be grouped into one
+ * row.
+ */
+function renderDashboardPart(part: UIMessage["parts"][number], i: number) {
+ const p = part as {
+ type: string;
+ text?: string;
+ url?: string;
+ title?: string;
+ state?: string;
+ };
+ const type = part.type as string;
+
+ if (type === "text") {
+ return p.text ? : null;
+ }
+
+ if (type.startsWith("tool-") && IN_FLIGHT_TOOL_STATES.has(p.state ?? "")) {
+ return ;
+ }
+
+ return renderPart(part, i);
+}
+
+/**
+ * A citation the panel can render as a docs button: a `source-url` part with a
+ * safe URL and something to label it with. Anything else falls through to the
+ * shared renderer.
+ */
+function citationFor(part: UIMessage["parts"][number]): { url: string; label: string } | null {
+ const p = part as { type: string; url?: string; title?: string };
+ if (p.type !== "source-url") return null;
+ const url = toSafeUrl(p.url);
+ const label = p.title || p.url;
+ return url && label ? { url, label } : null;
+}
+
+/**
+ * One citation, as a button.
+ *
+ * A citation into our own dashboard is a place in this app, so it navigates in
+ * place — as an absolute URL it would otherwise render as an external link and
+ * open a second copy of the dashboard in a new tab. Real external links keep
+ * their new tab.
+ */
+function CitationButton({ url, label }: { url: string; label: string }) {
+ const navigate = useNavigate();
+ const path = typeof window === "undefined" ? null : sameOriginPath(url, window.location.origin);
+
+ if (path) {
+ return (
+ navigate(path)}>
+ {label}
+
+ );
+ }
+
+ return (
+
+ {label}
+
+ );
+}
+
+/** The text of a user message: every text part, joined. */
+function userText(message: UIMessage): string {
+ return (
+ message.parts
+ ?.filter((part) => part.type === "text")
+ .map((part) => (part as { type: "text"; text: string }).text)
+ .join("") ?? ""
+ );
+}
+
+// Renders one message as one turn. A user turn is the accent bubble; assistant
+// parts go through the panel's own renderer, and a card-producing part
+// (render_view / get_report) becomes a catalog card instead of a tool row. A
+// wake — an assistant turn nobody asked for — keeps the same body under a banner.
+const DashboardAgentTurn = memo(function DashboardAgentTurn({
message,
+ onIntent,
+ resolveUri,
+ watches,
}: {
message: UIMessage;
+ onIntent?: (intent: AgentIntent) => void;
+ resolveUri?: (uri: string) => ResolvedUri | null;
+ watches?: WakeWatch[];
}) {
- if (message.role !== "assistant" || !message.parts?.some((p) => viewSpecFor(p))) {
- return ;
+ if (message.role === "user") {
+ return (
+
+
+
+ );
}
- return (
-
- );
+ if (message.role !== "assistant") return null;
+ const parts = message.parts ?? [];
+ if (parts.length === 0) return null;
+
+ const body: React.ReactNode[] = [];
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i]!;
+
+ const blocks = blocksFor(part);
+ if (blocks) {
+ body.push(
+
+
+
+ );
+ continue;
+ }
+
+ // Citations arrive one part each, but they read as a list of sources — so a
+ // run of consecutive ones becomes one wrapping row of docs buttons rather
+ // than a stack of full-width lines. A lone citation is that row with one
+ // button in it.
+ if (citationFor(part)) {
+ const start = i;
+ const buttons: React.ReactNode[] = [];
+ while (i < parts.length) {
+ const citation = citationFor(parts[i]!);
+ if (!citation) break;
+ buttons.push();
+ i++;
+ }
+ i--;
+ body.push({buttons});
+ continue;
+ }
+
+ body.push(renderDashboardPart(part, i));
+ }
+
+ // A wake narration is identified by the message id the agent wrote it under,
+ // so nothing about the parts has to change: same prose, with a banner above it
+ // saying the watch — not the user — started this turn.
+ const wake = wakeRefFromMessageId(message.id);
+ if (wake) {
+ return (
+
+
+ }
+ >
+ {body}
+
+
+ );
+ }
+
+ return {body};
});
-// Renders the conversation with the shared agent message renderer — the same
-// MessageBubble the run inspector and playground use, so agent output looks
-// identical everywhere — except where the agent emits a view-catalog block,
-// which renders as a rich card.
-export function DashboardAgentMessages({
+/**
+ * The turns of a conversation, without the transcript around them — for a host
+ * that owns its own `ChatTranscript` and interleaves other content between
+ * turns (the demo playbook does exactly this).
+ */
+export function DashboardAgentTurns({
messages,
- isThinking,
+ activity,
error,
-}: {
- messages: UIMessage[];
- isThinking: boolean;
- error?: Error;
-}) {
- const rootRef = useAutoScrollToBottom([messages, isThinking]);
+ onRetry,
+ onDismissError,
+ onIntent,
+ resolveUri,
+ watches,
+}: DashboardAgentMessagesProps) {
+ // One status line at a time: a tool's own progress beats the generic activity.
+ const showActivity = activity !== null && !hasToolProgressLine(messages);
+
+ return (
+ <>
+ {messages.map((message) => (
+
+ ))}
+ {showActivity && activity && (
+
+ {ACTIVITY_LABELS[activity]}
+
+ )}
+ {error && (
+
+
+
+ {onRetry && (
+
+ Try again
+
+ )}
+ {onDismissError && (
+
+ )}
+
+ )
+ }
+ >
+ {error.message}
+
+
+
+ )}
+ >
+ );
+}
+
+// The conversation in its own scroll column. Layout — the inset, the rhythm
+// between turns, where a card or a progress line sits — is `./chat-layout`'s;
+// this component only decides what each part turns into.
+export function DashboardAgentMessages(props: DashboardAgentMessagesProps) {
+ const rootRef = useTranscriptAutoScroll(props.messages, props.activity);
return (
-
+
+
+
);
}
+// #endregion chat-layout transcript
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx
index 403c2ce6d69..d41e9bd7cca 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx
@@ -3,6 +3,8 @@ import { useLocation } from "@remix-run/react";
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Spinner } from "~/components/primitives/Spinner";
+import { useToast } from "~/components/primitives/Toast";
+import { useAgentPageContext } from "~/hooks/useAgentPageContext";
import { useApiOrigin } from "~/hooks/useApiOrigin";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -14,18 +16,49 @@ import {
type DashboardAgentSession,
} from "./DashboardAgentChat";
import { DashboardAgentDraft } from "./DashboardAgentDraft";
+import type { TurnActivity } from "./DashboardAgentMessages";
import { DashboardAgentHeader } from "./DashboardAgentHeader";
import {
DashboardAgentHistory,
type DashboardAgentChat as DashboardAgentChatListItem,
} from "./DashboardAgentHistory";
+import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
+import type { AgentPageContext } from "./page-context-types";
+import { agentPageLabel } from "./page-label";
-// Restore the last open chat across panel re-opens and page reloads. Scoped by
-// org because chats are org-scoped. localStorage (not a cookie) since the panel
-// only mounts client-side — the server never needs this.
+// Restore the last open chat across panel re-opens and page reloads — but only
+// on the page it was last used on. A closed panel reopened on a DIFFERENT page
+// starts a fresh draft with that page's suggested prompts instead of dragging
+// the previous conversation along. Scoped by org because chats are org-scoped.
+// localStorage (not a cookie) since the panel only mounts client-side.
const lastChatStorageKey = (organizationId: string) =>
`tdev:dashboard-agent:last-chat:${organizationId}`;
+function readLastChat(storageKey: string): { chatId: string; path: string } | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = window.localStorage.getItem(storageKey);
+ if (!raw) return null;
+ // Pre-path entries were the bare chat id — no page to match, start fresh.
+ if (!raw.startsWith("{")) return null;
+ const parsed = JSON.parse(raw) as { chatId?: string; path?: string };
+ return parsed.chatId && parsed.path ? { chatId: parsed.chatId, path: parsed.path } : null;
+ } catch {
+ return null; // localStorage unavailable / corrupted — start fresh
+ }
+}
+
+// Undefined when the context can't be serialized (it should always be
+// JSON-safe — it comes from loader data — but the panel must not break if a
+// page's mapper puts something exotic in `filters`).
+function serializePageContext(pageContext: AgentPageContext): string | undefined {
+ try {
+ return JSON.stringify(pageContext);
+ } catch {
+ return undefined;
+ }
+}
+
type ActiveChat = {
chatId: string;
messages: UIMessage[];
@@ -46,23 +79,57 @@ type ActiveChat = {
* so the client never invents an id. Existing chats resolve their stored
* transcript + session before mounting `DashboardAgentChat` (keyed by chatId).
*/
-export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
+export function DashboardAgentPanel({
+ onClose,
+ requestedMessage,
+ openChatRequest,
+ promotedPrompt,
+ onChatRead,
+}: {
+ onClose: () => void;
+ // Text handed to the panel from outside (`openWith`). `seq` distinguishes
+ // repeat requests with the same text.
+ requestedMessage?: { text: string; seq: number };
+ // A specific chat to show, from outside the panel (a wake toast). `seq`
+ // distinguishes repeat requests for the same chat.
+ openChatRequest?: { chatId: string; seq: number };
+ // The product-controlled promoted prompt chip, from the feature flag.
+ promotedPrompt?: SuggestedPrompt;
+ // The chat in front of the user changed, so its watch wakes are seen — clears
+ // the launcher's unread dot.
+ onChatRead?: (chatId: string) => void;
+}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const user = useUser();
const apiOrigin = useApiOrigin();
const location = useLocation();
+ const pageContext = useAgentPageContext();
+ const toast = useToast();
+
+ const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
+ const storageKey = lastChatStorageKey(organization.id);
const [view, setView] = useState<"chat" | "history">("chat");
const [chats, setChats] = useState([]);
const [active, setActive] = useState(null);
- const [loading, setLoading] = useState(false);
+ // Starts true when there's a chat to restore ON THIS PAGE, so the first
+ // render already knows a restore is coming — an `openWith` request waits for
+ // it instead of racing it into a new chat.
+ const [loading, setLoading] = useState(
+ () => readLastChat(storageKey)?.path === location.pathname
+ );
- const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
- const storageKey = lastChatStorageKey(organization.id);
+ // What the banner shows. The agent gets the full pathname in `clientData`
+ // (below) — this is display text only, derived from the same page context the
+ // agent receives so the two can't disagree about where the user is.
+ const currentPage = agentPageLabel(pageContext, location.pathname);
- const currentPage = location.pathname.split("/").filter(Boolean).pop() ?? "overview";
+ // The page context is a fresh object every render, so key the clientData memo
+ // off its serialized form — otherwise every render would look like new
+ // per-turn context to the transport.
+ const pageContextKey = serializePageContext(pageContext);
const clientData = useMemo(
() => ({
@@ -71,17 +138,55 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
projectId: project.id,
environmentId: environment.id,
currentPage: location.pathname,
+ pageContext: pageContextKey ? (JSON.parse(pageContextKey) as AgentPageContext) : undefined,
}),
- [user.id, organization.id, project.id, environment.id, location.pathname]
+ [user.id, organization.id, project.id, environment.id, location.pathname, pageContextKey]
);
+ // Which chat has a turn in flight, for the History list's marker. Client-side
+ // only: nothing server-side records a live turn, so this is the chat the panel
+ // has open (or had open when History was opened — the turn keeps running).
+ const [thinkingChatId, setThinkingChatId] = useState(null);
+ const handleActivityChange = useCallback((chatId: string, activity: TurnActivity | null) => {
+ setThinkingChatId((previous) =>
+ activity !== null ? chatId : previous === chatId ? null : previous
+ );
+ }, []);
+
+ // The list is reloaded from several places at once (open, every settled turn, a
+ // watch change), so a single in-flight request is shared instead of stacking:
+ // callers that arrive while one is running await that one and see its result.
+ const historyInFlight = useRef | null>(null);
+
+ // Chats read since the last reload. The read POST and the reload it triggers
+ // can land out of order, so the next list is masked with what we know was
+ // read; the ids are then dropped, so a later wake in the same chat still shows.
+ const justRead = useRef>(new Set());
+
const loadHistory = useCallback(async () => {
- const res = await fetch(actionPath);
- if (res.ok) {
- const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] };
- setChats(data.chats ?? []);
- }
- }, [actionPath]);
+ if (historyInFlight.current) return historyInFlight.current;
+ const request = (async () => {
+ try {
+ const res = await fetch(actionPath);
+ if (!res.ok) throw new Error(`History request failed (${res.status})`);
+ const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] };
+ const read = justRead.current;
+ justRead.current = new Set();
+ setChats(
+ (data.chats ?? []).map((chat) =>
+ read.has(chat.id) ? { ...chat, hasUnreadWake: false } : chat
+ )
+ );
+ } catch (error) {
+ console.error("Dashboard agent: failed to load chat history", error);
+ toast.error("We couldn't load your previous chats. Try again in a moment.");
+ } finally {
+ historyInFlight.current = null;
+ }
+ })();
+ historyInFlight.current = request;
+ return request;
+ }, [actionPath, toast]);
// Bumped on each open so a slower earlier open can't overwrite a newer one
// when chats are switched rapidly.
@@ -97,6 +202,10 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
setLoading(true);
try {
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
+ if (!res.ok && res.status !== 404) {
+ console.error(`Dashboard agent: failed to open chat ${id} (${res.status})`);
+ toast.error("We couldn't open that chat. Try again in a moment.");
+ }
const data = res.ok
? ((await res.json()) as {
messages?: UIMessage[];
@@ -119,11 +228,15 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
// Nothing stored under this id — drop to a fresh draft.
setActive(null);
}
+ } catch (error) {
+ console.error(`Dashboard agent: failed to open chat ${id}`, error);
+ toast.error("We couldn't open that chat. Try again in a moment.");
+ if (seq === openChatRequestSeq.current) setActive(null);
} finally {
if (seq === openChatRequestSeq.current) setLoading(false);
}
},
- [actionPath]
+ [actionPath, toast]
);
// Start a new chat by sending its first message. The server generates the id,
@@ -156,6 +269,8 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
// A newer open/create (or New chat) superseded this one — drop the result.
if (seq !== openChatRequestSeq.current) return;
if (!res.ok || !data.chatId || !data.publicAccessToken) {
+ console.error(`Dashboard agent: failed to create chat (${res.status})`, data.error);
+ toast.error(data.error ?? "We couldn't start that chat. Try again in a moment.");
setActive(null);
return;
}
@@ -166,37 +281,113 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
pendingFirstMessage: data.headStarted ? undefined : text,
streaming: data.headStarted,
});
+ } catch (error) {
+ console.error("Dashboard agent: failed to create chat", error);
+ toast.error("We couldn't start that chat. Try again in a moment.");
+ if (seq === openChatRequestSeq.current) setActive(null);
} finally {
if (seq === openChatRequestSeq.current) setLoading(false);
}
},
- [actionPath, clientData]
+ [actionPath, clientData, toast]
);
// On open, restore the last chat if there is one; otherwise stay in the draft
- // state (active = null). Runs once per mount.
+ // state (active = null). Runs once per mount. The history list is loaded at the
+ // same time: it's one cheap request, it makes the History view instant, and
+ // it's where the header gets the active chat's title from.
const restored = useRef(false);
useEffect(() => {
if (restored.current) return;
restored.current = true;
- let stored: string | null = null;
- try {
- stored = window.localStorage.getItem(storageKey);
- } catch {
- /* localStorage unavailable — start fresh */
+ void loadHistory();
+ const stored = readLastChat(storageKey);
+ // Same page → pick the conversation back up. Different page → fresh draft
+ // with this page's prompts; the old chat stays one click away in History.
+ if (stored && stored.path === location.pathname) {
+ void openChat(stored.chatId);
+ } else {
+ // `loading` starts true only when there was something to restore here.
+ setLoading(false);
}
- if (stored) void openChat(stored);
- }, [openChat, storageKey]);
+ // location is deliberately not a dep: this is a mount-time decision.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [openChat, storageKey, loadHistory]);
+
+ // A chat asked for from outside: a wake toast is about one conversation, so it
+ // opens that one. Runs after the mount-time restore, and `openChat` invalidates
+ // any request already in flight, so the asked-for chat is the one that lands.
+ const handledOpenChatSeq = useRef(undefined);
+ useEffect(() => {
+ if (!openChatRequest || handledOpenChatSeq.current === openChatRequest.seq) return;
+ handledOpenChatSeq.current = openChatRequest.seq;
+ setView("chat");
+ // Already the visible transcript — nothing to load, and reloading it would
+ // drop a turn in flight.
+ if (openChatRequest.chatId === active?.chatId) return;
+ void openChat(openChatRequest.chatId);
+ // `active` is read, not tracked: a later change must not re-run the request.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [openChatRequest, openChat]);
- // Persist the active chat as the one to restore next time.
+ // Persist the active chat and the page it's being used on — navigating with
+ // the panel open keeps the chat, so the stored path follows along.
useEffect(() => {
if (!active?.chatId) return;
try {
- window.localStorage.setItem(storageKey, active.chatId);
+ window.localStorage.setItem(
+ storageKey,
+ JSON.stringify({ chatId: active.chatId, path: location.pathname })
+ );
} catch {
/* ignore */
}
- }, [active?.chatId, storageKey]);
+ }, [active?.chatId, storageKey, location.pathname]);
+
+ // A chat becoming the visible transcript is the user reading it — mark it read
+ // so the launcher's dot clears. Fires on open, on every chat switch, and when
+ // the History view is left for the chat again; a draft has nothing to read.
+ useEffect(() => {
+ if (view !== "chat" || !active?.chatId) return;
+ const chatId = active.chatId;
+ onChatRead?.(chatId);
+ // Clear the row's highlight now rather than waiting for the next history
+ // reload, so going back to History after reading doesn't show it as unread.
+ justRead.current.add(chatId);
+ setChats((previous) =>
+ previous.map((chat) => (chat.id === chatId ? { ...chat, hasUnreadWake: false } : chat))
+ );
+ // Read it again on the way out, so a wake that landed while the chat was in
+ // front of the user doesn't come back as unread when the panel closes.
+ return () => {
+ onChatRead?.(chatId);
+ justRead.current.add(chatId);
+ };
+ }, [view, active?.chatId, onChatRead]);
+
+ // Text handed in by `openWith`. With no chat open we start one and send it
+ // straight away (the launcher's caller already knows what to ask); with a chat
+ // open we only drop it into the composer, so we never inject a message into
+ // the middle of someone's conversation. Waits for an in-flight restore/open so
+ // it can tell which of the two it is.
+ // The prefill is bound to the chat it was meant for: DashboardAgentChat
+ // remounts (fresh guard ref) on every chat switch, so an unbound prefill
+ // would re-apply to each new chat and stomp whatever the user typed.
+ const [prefill, setPrefill] = useState<{ text: string; seq: number; chatId: string } | undefined>(
+ undefined
+ );
+ const handledRequestSeq = useRef(undefined);
+ useEffect(() => {
+ if (!requestedMessage || loading) return;
+ if (handledRequestSeq.current === requestedMessage.seq) return;
+ handledRequestSeq.current = requestedMessage.seq;
+ setView("chat");
+ if (active) {
+ setPrefill({ ...requestedMessage, chatId: active.chatId });
+ } else {
+ void createChat(requestedMessage.text);
+ }
+ }, [requestedMessage, loading, active, createChat]);
const newChat = useCallback(() => {
// Invalidate any in-flight open/create so its result can't replace the draft.
@@ -218,11 +409,49 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
const body = new FormData();
body.set("intent", "delete");
body.set("chatId", id);
- await fetch(actionPath, { method: "POST", body });
+ try {
+ const res = await fetch(actionPath, { method: "POST", body });
+ if (!res.ok) throw new Error(`Delete failed (${res.status})`);
+ } catch (error) {
+ console.error("Dashboard agent: failed to delete chat", error);
+ toast.error("We couldn't delete that chat. Try again in a moment.");
+ return;
+ }
+ setThinkingChatId((previous) => (previous === id ? null : previous));
if (id === active?.chatId) newChat();
void loadHistory();
},
- [actionPath, active?.chatId, newChat, loadHistory]
+ [actionPath, active?.chatId, newChat, loadHistory, toast]
+ );
+
+ // Stop watching, from the chip's ×. The chip goes immediately (the cancel is a
+ // single guarded UPDATE and hardly ever fails), and the reload right after
+ // restores the truth either way.
+ const cancelWatch = useCallback(
+ async (watchId: string) => {
+ const chatId = active?.chatId;
+ if (!chatId) return;
+ setChats((previous) =>
+ previous.map((chat) =>
+ chat.id === chatId
+ ? { ...chat, watches: (chat.watches ?? []).filter((watch) => watch.id !== watchId) }
+ : chat
+ )
+ );
+ const body = new FormData();
+ body.set("intent", "watch-cancel");
+ body.set("chatId", chatId);
+ body.set("watchId", watchId);
+ try {
+ const res = await fetch(actionPath, { method: "POST", body });
+ if (!res.ok) throw new Error(`Watch cancel failed (${res.status})`);
+ } catch (error) {
+ console.error("Dashboard agent: failed to cancel watch", error);
+ toast.error("We couldn't stop that watch. Try again in a moment.");
+ }
+ void loadHistory();
+ },
+ [actionPath, active?.chatId, loadHistory, toast]
);
const toggleHistory = useCallback(() => {
@@ -232,10 +461,25 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
});
}, [loadHistory]);
+ // The header names what you're looking at. Titles come from the history list
+ // (the agent writes one when the first turn settles), so a brand-new chat has
+ // none yet and falls back to "Chat".
+ const activeChat = active ? chats.find((chat) => chat.id === active.chatId) : undefined;
+ const headerTitle =
+ view === "history" ? "Chat history" : active ? (activeChat?.title ?? "Chat") : "New chat";
+
+ // The watches ride along on the history list (one query for every chat), so
+ // they refresh whenever it does: on open, when a turn settles, and after a
+ // watch is created or cancelled. The FULL list goes down — the chips filter
+ // to active themselves (a chip is an offer to cancel), while the wake banner
+ // needs the kind of a watch that has already fired.
+ const chatWatches = activeChat?.watches ?? [];
+
return (
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx
index f78bc5a663a..33fcc128450 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx
@@ -1,19 +1,69 @@
-import { SparklesIcon } from "@heroicons/react/20/solid";
+import { SparklesIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
+import { useCallback, useMemo, useState } from "react";
import { Paragraph } from "~/components/primitives/Paragraph";
+import { AgentList, AgentListRow, AgentListRowAction } from "./list-row";
+import {
+ readDismissedPromptIds,
+ resolveSuggestedPrompts,
+ writeDismissedPromptId,
+} from "./suggested-prompts";
-// Static for now; later these can be page-aware (per currentPage) or server-driven.
-const SUGGESTED_PROMPTS = [
- "What can you help me with?",
- "How do retries work in Trigger.dev?",
- "Where do I set environment variables?",
- "Explain what this page shows.",
-];
-
+/**
+ * The chips on an empty chat.
+ *
+ * Everything shown here comes from the registry
+ * (`suggested-prompts/`) resolved against the page the user is on — there is no
+ * hardcoded list. Clicking a chip sends its `prompt` (the full question),
+ * not its `label` (the short chip text). Dismissing one hides it for this user
+ * and pulls the next candidate up into the row.
+ */
export function DashboardAgentSuggestedPrompts({
onSelect,
+ pageContext,
+ promoted,
+ dismissedIds,
}: {
+ /** Receives the prompt text to send, not the chip label. */
onSelect: (prompt: string) => void;
+ /** What the user is looking at. Omitted (no route mapper) means defaults only. */
+ pageContext?: AgentPageContext;
+ /** The product-controlled promoted chip, when one is configured. */
+ promoted?: SuggestedPrompt;
+ /**
+ * Controlled dismissals. Normally omitted: the component reads and writes its
+ * own localStorage. Passing this makes it stateless, which is what the
+ * storybook gallery and tests want.
+ */
+ dismissedIds?: string[];
}) {
+ const controlled = dismissedIds !== undefined;
+ // Read once on mount: localStorage isn't reactive, and re-reading per render
+ // would fight the local state below.
+ const [storedDismissedIds, setStoredDismissedIds] = useState(() =>
+ controlled ? [] : readDismissedPromptIds()
+ );
+
+ const dismiss = useCallback(
+ (promptId: string) => {
+ if (controlled) return;
+ writeDismissedPromptId(promptId);
+ setStoredDismissedIds((ids) => (ids.includes(promptId) ? ids : [...ids, promptId]));
+ },
+ [controlled]
+ );
+
+ const effectiveDismissedIds = dismissedIds ?? storedDismissedIds;
+
+ const prompts = useMemo(
+ () =>
+ resolveSuggestedPrompts(pageContext ?? { page: { kind: "other", path: "" }, signals: [] }, {
+ promoted,
+ dismissedIds: effectiveDismissedIds,
+ }),
+ [pageContext, promoted, effectiveDismissedIds]
+ );
+
return (
@@ -22,18 +72,23 @@ export function DashboardAgentSuggestedPrompts({
Ask about your runs, errors, or how Trigger.dev works.
);
}
diff --git a/apps/webapp/app/components/dashboard-agent/InvestigateButton.tsx b/apps/webapp/app/components/dashboard-agent/InvestigateButton.tsx
new file mode 100644
index 00000000000..b8972de6fd7
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/InvestigateButton.tsx
@@ -0,0 +1,52 @@
+import { ChatBubbleLeftRightIcon } from "@heroicons/react/20/solid";
+import { Button } from "~/components/primitives/Buttons";
+import { useDashboardAgent } from "./dashboardAgentLauncher";
+
+/**
+ * The dashboard's "Investigate" button: opens the agent panel with `prompt`
+ * already in play.
+ *
+ * Renders nothing when the agent isn't available (no provider, or gated off) —
+ * every entry point self-hides, so callers don't need their own gate. Icon and
+ * accent match the launcher so the agent surfaces read as one thing.
+ */
+export function InvestigateButton({
+ prompt,
+ label = "Investigate",
+ size = "small",
+ variant = "primary",
+ fullWidth,
+ className,
+ tooltip,
+}: {
+ /** What to ask. Build it with the helpers in `investigate-prompts.ts`. */
+ prompt: string;
+ label?: string;
+ size?: "small" | "medium";
+ /** `primary` for the standalone accent button, `minimal` inside menus. */
+ variant?: "primary" | "minimal";
+ fullWidth?: boolean;
+ className?: string;
+ tooltip?: string;
+}) {
+ const agent = useDashboardAgent();
+ if (!agent) {
+ return null;
+ }
+
+ return (
+ agent.openWith(prompt)}
+ >
+ {label}
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts b/apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts
new file mode 100644
index 00000000000..c2f12438093
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts
@@ -0,0 +1,34 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+/**
+ * `InvestigationCard` is a pure component: props in, markup out. That's what
+ * lets the same card render in the panel, in the storybook gallery, and in any
+ * future host — and it's easy to break with one convenient `useLoaderData`. So
+ * it's asserted here rather than left to review (same guard as `ReportView`).
+ *
+ * The one hook it may use is `useState`, for the hypotheses disclosure: that's
+ * local UI state, not host data.
+ */
+const source = readFileSync(new URL("./InvestigationCard.tsx", import.meta.url), "utf8");
+
+describe("InvestigationCard purity", () => {
+ it("imports nothing from Remix", () => {
+ expect(source).not.toMatch(/from\s+"@remix-run\//);
+ });
+
+ it("imports no app hooks and no server module", () => {
+ expect(source).not.toMatch(/from\s+"~\/hooks\//);
+ expect(source).not.toMatch(/\.server"/);
+ });
+
+ it("calls no hook other than useState", () => {
+ const hooks = [...source.matchAll(/\buse([A-Z]\w*)\(/g)].map((match) => `use${match[1]}`);
+ expect([...new Set(hooks)]).toEqual(["useState"]);
+ });
+
+ it("resolves evidence URIs through the host, never a route of its own", () => {
+ expect(source).toMatch(/resolveUri/);
+ expect(source).not.toMatch(/\/orgs\//);
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx b/apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx
new file mode 100644
index 00000000000..4409aab7978
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/InvestigationCard.tsx
@@ -0,0 +1,254 @@
+/**
+ * The investigation card — the panel's rendering of an `investigation` view block.
+ *
+ * The anatomy is the one the design review approved on `DemoInvestigationCard`
+ * (bordered card, header strip with badges, `Section` bodies, collapsed verdict
+ * with expandable hypotheses, progress line outside the card). The difference is
+ * the data: this reads the validated contracts payload, and the demo card stays
+ * where it is as the reviewed reference.
+ *
+ * An investigation is the one *progressive* block: its `id` is the
+ * investigationId and its `revision` climbs, so re-emitting it replaces this card
+ * rather than stacking a second one (see `view-blocks.ts`).
+ *
+ * PURE COMPONENT, like `ReportView`: props in, markup out. No Remix hooks, no
+ * loader data, no router context — which is what lets it render in the panel and
+ * in the storybook gallery from the same fixture. `useState` for the disclosure
+ * is local UI state, not host data. `trigger://` evidence URIs are resolved by
+ * the HOST through `resolveUri`; without a resolver the raw URI is shown, which
+ * is still the check that the agent cited something addressable.
+ */
+import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
+import type {
+ Evidence,
+ HypothesisVerdict,
+ InvestigationBlock,
+ InvestigationHypothesis,
+ InvestigationSeverity,
+} from "@internal/dashboard-agent-contracts";
+import { useState } from "react";
+import { Button } from "~/components/primitives/Buttons";
+import { Callout } from "~/components/primitives/Callout";
+import { Spinner } from "~/components/primitives/Spinner";
+import {
+ CategoryBadge,
+ ConfidenceBadge,
+ EVIDENCE_ROW_CLASS,
+ SeverityBadge,
+ VerdictBadge,
+} from "./agent-badges";
+import { ChatProgress } from "./chat-layout";
+import type { ResolvedUri } from "./ReportView";
+
+const SEVERITY_LABELS: Record = {
+ info: "Info",
+ warn: "Degraded",
+ crit: "Critical",
+};
+
+const VERDICT_LABELS: Record = {
+ testing: "Testing",
+ validated: "Validated",
+ invalidated: "Ruled out",
+};
+
+type ResolveUri = (uri: string) => ResolvedUri | null;
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
+
+/** One citation: its kind, its label, the resource it points at, and a snippet. */
+function EvidenceItem({
+ evidence,
+ stacked,
+ resolveUri,
+}: {
+ evidence: Evidence;
+ stacked?: boolean;
+ resolveUri?: ResolveUri;
+}) {
+ const resolved = resolveUri?.(evidence.uri) ?? null;
+ return (
+
+ {/* `w-fit` is what keeps the badge its own size when it is a block child of
+ the stacked item — the Badge primitive is a grid, so it would otherwise
+ stretch the full width and read as a bar. */}
+ {evidence.kind}
+
: null}
+ {hypothesis.evidence.length > 0 ? (
+ // Stacked, not two-column: nested under the hypothesis's indent the
+ // content column would be too narrow for identifiers and excerpts. The
+ // wide gap is deliberate — stacked items have no column to separate them,
+ // so the space between them is the only thing that says "next citation".
+
+ {/* Its own truncating line — the badge row's right corner can't hold a
+ run id reliably at panel width (same rule as RunDiagnosisCard). */}
+ {investigation.runId ? (
+
{investigation.runId}
+ ) : null}
+
+
+
+
{investigation.title}
+
+
+
{investigation.headline}
+
+
+ {/* A fix is only ever shown for a concluded investigation; an
+ inconclusive one gets "What to check next" instead. The schema
+ enforces the exclusivity, so this can't render both. */}
+ {concluded && investigation.remediation ? (
+
+
+ {/* Progress lives outside the card, on the left — the same line the chat
+ uses for "Working…" and in-flight tools. `ChatProgress` carries the
+ transcript's alignment itself, so it lines up with the card above it
+ instead of hugging the card's border. */}
+ {inProgress ? {investigation.progress ?? "Working…"} : null}
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/ReportView.test.ts b/apps/webapp/app/components/dashboard-agent/ReportView.test.ts
new file mode 100644
index 00000000000..74603b325a2
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/ReportView.test.ts
@@ -0,0 +1,26 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+/**
+ * `ReportView` is a pure component: props in, markup out. That's the property
+ * that lets the same card render in the panel, in the storybook gallery, and in
+ * any future host — and it's easy to break with one convenient `useLoaderData`.
+ * So it's asserted here rather than left to review.
+ */
+const source = readFileSync(new URL("./ReportView.tsx", import.meta.url), "utf8");
+
+describe("ReportView purity", () => {
+ it("imports nothing from Remix", () => {
+ expect(source).not.toMatch(/from\s+"@remix-run\//);
+ });
+
+ it("imports no hooks and no server module", () => {
+ expect(source).not.toMatch(/from\s+"~\/hooks\//);
+ expect(source).not.toMatch(/\.server"/);
+ });
+
+ it("calls no React hook of its own", () => {
+ // A pure render needs no state, no effects, no context.
+ expect(source).not.toMatch(/\buse[A-Z]\w*\(/);
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/ReportView.tsx b/apps/webapp/app/components/dashboard-agent/ReportView.tsx
new file mode 100644
index 00000000000..cf3889fbde0
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/ReportView.tsx
@@ -0,0 +1,639 @@
+/**
+ * The report card — the panel's rendering of a `report` view block.
+ *
+ * A report is a snapshot of a `ReportViewModel`: numbers plus message *codes*,
+ * never prose. Every string on this card is resolved through the report's own
+ * message catalog (`report-messages.ts`), which is also what the markdown and
+ * ANSI renderers use — so the chat card, the CLI and the agent's own grounding
+ * can't drift into three different vocabularies.
+ *
+ * The layout reads top to bottom as one argument: a quiet header line, one
+ * headline that names the state, the metric grid that proves it, "why:" for what
+ * owns it, "read:" for what it means, and a footer that puts the actions in a
+ * sentence. The pieces live in `report-sparkline.tsx`, shared with the demo card.
+ *
+ * PURE COMPONENT, on purpose: props in, no Remix hooks, no loader data, no
+ * router context. That's what lets it render identically in the panel, in the
+ * storybook gallery, and (later) in any other host. Two consequences:
+ *
+ * - `trigger://` URIs are resolved by the HOST via the `resolveUri` prop. The
+ * card knows a URI is a resource pointer; only the host knows the environment
+ * it should resolve against.
+ * - Footer actions inside the app don't navigate. They emit an `AgentIntent` and
+ * the host decides whether to honour it — the same contract the rest of the
+ * agent uses. Only entries whose target is an external URL are real links.
+ */
+import {
+ isTriggerUri,
+ type AgentIntent,
+ type ReportFindingPayload,
+ type ReportMetricPayload,
+ type ReportUnit,
+ type ReportViewModelPayload,
+ type TriggerUri,
+} from "@internal/dashboard-agent-contracts";
+import { type ReactNode } from "react";
+import { Badge } from "~/components/primitives/Badge";
+// Imported for its registration side effect as much as its value: each report's
+// catalog registers itself under the report's title on import.
+import { healthMessages } from "~/presenters/v3/reports/health/health-messages";
+import { type ReportMessages } from "~/presenters/v3/reports/report-messages";
+import { reportIsTrustworthy } from "./report-block-adapter";
+import {
+ FOOTER_WATCH_CODE,
+ ReportBody,
+ ReportCard,
+ ReportFindingLine,
+ ReportFooterAction,
+ ReportFooterActionLink,
+ ReportFooterLine,
+ ReportFooterLink,
+ ReportFooterNote,
+ ReportHeaderLine,
+ ReportHeadline,
+ ReportMetricList,
+ ReportMetricRow,
+ ReportNoteBlock,
+ ReportProse,
+ ReportProvenance,
+ ReportSeverityIcon,
+ reportDelta,
+ reportFooterStyle,
+ type ReportFooterItem,
+} from "./report-sparkline";
+
+export type ResolvedUri = { label: string; url: string };
+
+/** How often a recovery watch polls, and how long it lives. Aggregate conditions floor at 5m. */
+const RECOVERY_WATCH = { checkEveryMinutes: 5, maxHours: 6 } as const;
+
+// --- messages ---------------------------------------------------------------
+
+/**
+ * Codes resolve through the report's own catalog. A report with no registered
+ * catalog (an old transcript, a report that hasn't shipped its messages) falls
+ * back to showing the raw codes — thin, but never a crash and never invented
+ * prose.
+ */
+const PASSTHROUGH_MESSAGES: ReportMessages = {
+ metricLabel: (id) => id,
+ findingReason: (_type, reason) => reason,
+ readMessage: (code) => code,
+ exclusionMessage: (code) => code,
+ observationMessage: (code) => code,
+ annotationMessage: (code) => code,
+ statementMessage: (findingType, severity) => `${findingType} ${severity}`,
+ actionMessage: (code) => code,
+};
+
+const CATALOGS: Record = { health: healthMessages };
+
+function messagesFor(title: string): ReportMessages {
+ return CATALOGS[title] ?? PASSTHROUGH_MESSAGES;
+}
+
+// --- formatting -------------------------------------------------------------
+// The markdown renderer keeps its formatters private, so these mirror them. If
+// they ever become shared, this is the block that goes.
+
+function fmtDuration(ms: number): string {
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ const s = ms / 1000;
+ if (s < 60) return Number.isInteger(s) ? `${s}s` : `${s.toFixed(1)}s`;
+ const m = s / 60;
+ return Number.isInteger(m) ? `${m}m` : `${m.toFixed(1)}m`;
+}
+
+function fmtValue(value: number, unit: ReportUnit): string {
+ switch (unit) {
+ case "ms":
+ return fmtDuration(value);
+ case "ratio":
+ return `${(value * 100).toFixed(1)}%`;
+ case "perMin":
+ return `${value > 0 ? "+" : value < 0 ? "−" : ""}${Math.abs(Math.round(value)).toLocaleString(
+ "en-US"
+ )}/min`;
+ case "count":
+ default:
+ return Math.round(value).toLocaleString("en-US");
+ }
+}
+
+function fmtCount(value: number): string {
+ return Math.round(value).toLocaleString("en-US");
+}
+
+/** Fill the `{token}` placeholders the message catalog leaves for the renderer. */
+function fillTokens(text: string, tokens: Record): string {
+ return text.replace(/\{(\w+)\}/g, (whole, key: string) =>
+ tokens[key] === undefined ? whole : String(tokens[key])
+ );
+}
+
+function metricTokens(
+ vm: ReportViewModelPayload,
+ metric: ReportMetricPayload
+): Record {
+ return {
+ value: metric.annotation?.value ?? metric.value,
+ window: vm.windowMinutes,
+ limit: metric.breakdown?.limit ?? "",
+ };
+}
+
+function findingTokens(vm: ReportViewModelPayload): Record {
+ const metric = (id: string) => vm.metrics.find((m) => m.id === id);
+ const triggered = metric("triggered");
+ const throughput = metric("throughput");
+ const liveness = metric("liveness");
+ return {
+ mult: triggered?.delta?.mult ?? "",
+ rate: Math.round(throughput?.breakdown?.done ?? 0),
+ age: liveness ? fmtDuration(liveness.value) : "",
+ };
+}
+
+// --- links ------------------------------------------------------------------
+
+/**
+ * What a `vm.links` entry points at. A report link is either an external doc URL
+ * or a `trigger://` URI naming a resource in the environment — the card treats
+ * them differently because only the host can turn a URI into a route.
+ */
+type LinkTarget =
+ | { kind: "none" }
+ | { kind: "external"; url: string }
+ | { kind: "resource"; uri: TriggerUri; resolved: ResolvedUri | null };
+
+function classifyLink(
+ url: string | undefined,
+ resolveUri: ((uri: string) => ResolvedUri | null) | undefined
+): LinkTarget {
+ if (!url) return { kind: "none" };
+ if (isTriggerUri(url)) return { kind: "resource", uri: url, resolved: resolveUri?.(url) ?? null };
+ if (/^https?:\/\//i.test(url)) return { kind: "external", url };
+ return { kind: "none" };
+}
+
+/**
+ * One footer entry, rendered the way its code says (`reportFooterStyle`): a
+ * primary button for something that happens, the docs button for something to
+ * read, a text link for a place to look, prose for an option.
+ *
+ * An in-app action emits a `navigate` intent — or an `ask`, when the report named
+ * no target, so the user can still pull the "how" out of the agent.
+ */
+function footerEntryNode({
+ code,
+ label,
+ target,
+ onIntent,
+}: {
+ code: string;
+ label: string;
+ target: LinkTarget;
+ onIntent?: (intent: AgentIntent) => void;
+}): ReactNode {
+ const style = reportFooterStyle(code);
+
+ if (style === "note") return {label};
+
+ // A docs entry is ALWAYS the docs button, whatever shape its link arrived in —
+ // external URL, resolved resource, or nothing (then its canonical docs page).
+ if (style === "docs") {
+ const href =
+ target.kind === "external"
+ ? target.url
+ : target.kind === "resource" && target.resolved
+ ? target.resolved.url
+ : DOCS_URL_FALLBACK[code];
+ if (href) {
+ return (
+
+ {label}
+
+ );
+ }
+ return {label};
+ }
+
+ if (style === "reference") {
+ const href =
+ target.kind === "external"
+ ? target.url
+ : target.kind === "resource" && target.resolved
+ ? target.resolved.url
+ : REFERENCE_URL_FALLBACK[code];
+ if (href) {
+ return (
+
+ {label}
+
+ );
+ }
+ return {label};
+ }
+
+ // An action whose target is a URL stays a button — contacting us about a limit
+ // is an action even though it opens a web form; the arrow says it leaves.
+ const actionHref =
+ target.kind === "external"
+ ? target.url
+ : target.kind === "none"
+ ? ACTION_URL_FALLBACK[code]
+ : undefined;
+ if (actionHref) {
+ return {label};
+ }
+
+ const intent: AgentIntent =
+ target.kind === "resource"
+ ? { kind: "navigate", target: target.uri }
+ : { kind: "ask", prompt: `How do I ${lowerFirst(label)}?` };
+
+ if (!onIntent) return {label};
+
+ return onIntent(intent)}>{label};
+}
+
+function lowerFirst(text: string): string {
+ return text.charAt(0).toLowerCase() + text.slice(1);
+}
+
+/**
+ * Canonical docs pages for footer codes whose report entry carries no URL —
+ * without one the entry silently degrades to prose, which reads as a bug.
+ */
+const DOCS_URL_FALLBACK: Record = {
+ concurrency_docs: "https://trigger.dev/docs/queue-concurrency",
+ retries_docs: "https://trigger.dev/docs/errors-retrying",
+ queues_docs: "https://trigger.dev/docs/queues",
+};
+
+/**
+ * Canonical destinations for action codes whose report entry carries no URL, so
+ * the button opens the page directly instead of falling back to asking the agent
+ * how to get there. Unknown codes keep the `ask` fallback.
+ */
+const ACTION_URL_FALLBACK: Record = {
+ contact_us_raise_limit: "https://trigger.dev/contact",
+};
+
+/** Same idea for cited references: a place to look must stay a link. */
+const REFERENCE_URL_FALLBACK: Record = {
+ check_control_plane: "https://status.trigger.dev",
+ check_platform_status: "https://status.trigger.dev",
+};
+
+// --- pieces -----------------------------------------------------------------
+
+function MetricRow({
+ vm,
+ metric,
+ messages,
+ anomalyMinutes,
+ hero,
+}: {
+ vm: ReportViewModelPayload;
+ metric: ReportMetricPayload;
+ messages: ReportMessages;
+ anomalyMinutes?: number;
+ /** The metric that explains the finding: its annotation is spelled out inline. */
+ hero?: boolean;
+}) {
+ const annotation = metric.annotation
+ ? fillTokens(messages.annotationMessage(metric.annotation.code), metricTokens(vm, metric))
+ : undefined;
+ const note = annotation
+ ? annotation
+ : metric.normal !== undefined
+ ? `normal ~${fmtValue(metric.normal, metric.unit)}`
+ : metric.series?.kind === "estimated"
+ ? "Estimated from a proxy signal, so read it as a shape, not a number."
+ : undefined;
+
+ const composite = metric.unit === "perMin" && metric.breakdown?.done !== undefined;
+
+ return (
+ fmtValue(value, metric.unit)}
+ />
+ );
+}
+
+/**
+ * A finding's evidence: its metric grid, then the "why:" block — who owns the
+ * problem, and what it isn't. The finding's own verdict is a headline or a
+ * finding line above; this is only what backs it up.
+ */
+function FindingBody({
+ vm,
+ finding,
+ messages,
+ tokens,
+}: {
+ vm: ReportViewModelPayload;
+ finding: ReportFindingPayload;
+ messages: ReportMessages;
+ tokens: Record;
+}) {
+ const metrics = finding.metricIds
+ .map((id) => vm.metrics.find((m) => m.id === id))
+ .filter((m): m is ReportMetricPayload => m !== undefined);
+
+ // The anomaly window describes the finding's *driving* metric — the first id,
+ // since degraded findings list theirs in causal order — so only that one
+ // sparkline highlights it.
+ const anomalyMinutes = finding.anomalyWindow?.touchesEnd
+ ? finding.anomalyWindow.minutes
+ : undefined;
+
+ return (
+
{step}
@@ -215,7 +259,7 @@ export function RunDiagnosisCard({ block }: { block: DiagnosisBlock }) {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
-
+
{title}
{children}
diff --git a/apps/webapp/app/components/dashboard-agent/WakeBanner.tsx b/apps/webapp/app/components/dashboard-agent/WakeBanner.tsx
new file mode 100644
index 00000000000..509dab10692
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/WakeBanner.tsx
@@ -0,0 +1,121 @@
+/**
+ * The banner above a wake narration.
+ *
+ * A wake arrives unprompted: nobody typed anything, the watch fired and the chat
+ * spoke. Rendered as plain assistant prose that reads like an answer to a
+ * question the user never asked — so the narration gets a banner that says what
+ * happened before the text does, with the outcome carried by a coloured accent
+ * and icon (the same rule the run status cells and the watch chips follow: the
+ * text keeps its colour, the state is the icon's job).
+ *
+ * A wake is identified by its message id — `wake:watch:{watchId}:{fired|expired}`,
+ * written by the agent's `narrateWatchWake` — so no protocol change is needed to
+ * spot one in the transcript.
+ */
+import { CheckCircleIcon, ClockIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
+import { cn } from "~/utils/cn";
+import { type AgentTone, TONE_ICON_COLOR } from "./agent-badges";
+
+const WAKE_ID_PREFIX = "wake:watch:";
+
+export type WakeOutcome = "fired" | "expired";
+
+/** The watch fields a banner can use. A `WatchChip` satisfies it. */
+export type WakeWatch = {
+ id: string;
+ kind: string;
+ note: string;
+ identity: string;
+};
+
+export type WakeRef = { watchId: string; outcome: WakeOutcome };
+
+/**
+ * The watch a message narrates the wake of, or null when the message isn't a
+ * wake. The id is `wake:watch:{watchId}:{outcome}`; a watch id never ends in an
+ * outcome word, so splitting on the last colon is unambiguous.
+ */
+export function wakeRefFromMessageId(messageId: string): WakeRef | null {
+ if (!messageId.startsWith(WAKE_ID_PREFIX)) return null;
+ const rest = messageId.slice(WAKE_ID_PREFIX.length);
+ const split = rest.lastIndexOf(":");
+ if (split <= 0) return null;
+ const outcome = rest.slice(split + 1);
+ if (outcome !== "fired" && outcome !== "expired") return null;
+ return { watchId: rest.slice(0, split), outcome };
+}
+
+/** The watch a wake belongs to, when the host passed its watches down. */
+export function findWakeWatch(watches: WakeWatch[] | undefined, watchId: string) {
+ return watches?.find((watch) => watch.id === watchId);
+}
+
+// Kinds whose condition being met is good news. `error_recurrence` is the
+// inverse: it fires because something broke again.
+const GOOD_NEWS_KINDS = new Set(["health_recovery", "backlog_drain", "run_start", "run_finished"]);
+
+function toneFor(outcome: WakeOutcome, kind: string | undefined): AgentTone {
+ if (outcome === "expired") return "neutral";
+ if (kind === "error_recurrence") return "error";
+ if (kind && GOOD_NEWS_KINDS.has(kind)) return "success";
+ // Fired, but we don't know what for: say so without claiming an outcome.
+ return "neutral";
+}
+
+function headlineFor(tone: AgentTone, outcome: WakeOutcome): string {
+ if (outcome === "expired") return "Watch expired — no answer";
+ switch (tone) {
+ case "success":
+ return "Watch update — all clear";
+ case "error":
+ return "Watch update — needs your attention";
+ default:
+ return "Watch update — condition met";
+ }
+}
+
+const TONE_ICON: Record JSX.Element> = {
+ neutral: ClockIcon,
+ success: CheckCircleIcon,
+ warning: ExclamationTriangleIcon,
+ error: ExclamationTriangleIcon,
+};
+
+const TONE_FRAME: Record = {
+ neutral: "border-l-border-bright bg-background-bright/40",
+ success: "border-l-success bg-success/10",
+ warning: "border-l-warning bg-warning/10",
+ error: "border-l-error bg-error/10",
+};
+
+/** What the watch was for: the user's own words, else whatever names it. */
+function subline(watch: WakeWatch | undefined): string {
+ const note = watch?.note.trim();
+ if (note) return note;
+ if (watch?.identity) return watch.identity;
+ if (watch?.kind) return watch.kind;
+ return "The watch woke this chat up on its own.";
+}
+
+export function WakeBanner({
+ outcome,
+ watch,
+}: {
+ outcome: WakeOutcome;
+ /** The watch that woke, when the host has it. Absent: kind-agnostic wording. */
+ watch?: WakeWatch;
+}) {
+ const tone = toneFor(outcome, watch?.kind);
+ const Icon = TONE_ICON[tone];
+ return (
+
+
+
+
{headlineFor(tone, outcome)}
+
{subline(watch)}
+
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/WatchChips.tsx b/apps/webapp/app/components/dashboard-agent/WatchChips.tsx
new file mode 100644
index 00000000000..b96efcbf67d
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/WatchChips.tsx
@@ -0,0 +1,105 @@
+/**
+ * The watch chip row. One chip per watch, its state carried by a coloured icon,
+ * with a cancel affordance on the active ones only.
+ *
+ * A chip has to answer "what is being watched, and is it still watching?" at a
+ * glance in a 380px panel — hence the short label plus a state icon, with the
+ * note and cadence in a tooltip rather than on screen. The label text keeps the
+ * default colour: only the icon is coloured, the same rule the run status cells
+ * follow.
+ */
+import { CheckCircleIcon, ClockIcon, NoSymbolIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import type { WatchStatus } from "@internal/dashboard-agent-contracts";
+import { Spinner } from "~/components/primitives/Spinner";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import { cn } from "~/utils/cn";
+import { type AgentTone, TONE_ICON_COLOR } from "./agent-badges";
+import { watchChipLabel, watchChipTooltip } from "./watch-chips";
+
+/** One watch, as the panel's loader hands it over (dates already JSON strings). */
+export type WatchChip = {
+ id: string;
+ identity: string;
+ status: WatchStatus;
+ kind: string;
+ note: string;
+ checkEveryMinutes: number;
+ expiresAt: string;
+};
+
+const STATUS_TONE: Record = {
+ active: "neutral",
+ fired: "success",
+ expired: "neutral",
+ cancelled: "neutral",
+};
+
+function StatusIcon({ status }: { status: WatchStatus }) {
+ const className = cn("size-3.5 shrink-0", TONE_ICON_COLOR[STATUS_TONE[status]]);
+ switch (status) {
+ case "active":
+ // Same choice as an executing run: a spinner is the "still going" state.
+ return ;
+ case "fired":
+ return ;
+ case "expired":
+ return ;
+ case "cancelled":
+ return ;
+ }
+}
+
+export function WatchChips({
+ watches,
+ onCancel,
+}: {
+ watches: WatchChip[];
+ /** Stop watching. Only offered on an active watch. */
+ onCancel?: (watchId: string) => void;
+}) {
+ if (watches.length === 0) return null;
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/WatchWakeToast.tsx b/apps/webapp/app/components/dashboard-agent/WatchWakeToast.tsx
new file mode 100644
index 00000000000..d275902ad48
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/WatchWakeToast.tsx
@@ -0,0 +1,122 @@
+/**
+ * #13 The dashboard-wide signal that a watch woke a chat while the panel was
+ * closed. The launcher's dot is easy to miss, so a wake also raises a toast.
+ *
+ * Persistent by design: a wake is the answer to a question the user asked
+ * minutes or hours ago, so it waits until it's dismissed rather than expiring on
+ * a 5s timer. Dismissing does NOT mark the chat read — reading happens in the
+ * panel, so the dot survives a swatted toast.
+ *
+ * The content is the standard `Callout` in its `agent` variant (the launcher's
+ * chat icon, the agent's indigo accent), inside the sonner shell the app's other
+ * toasts use — so a wake looks like everything else the dashboard says, not like
+ * a one-off panel.
+ */
+import { XMarkIcon } from "@heroicons/react/20/solid";
+import { toast } from "sonner";
+import { Button } from "~/components/primitives/Buttons";
+import { Callout } from "~/components/primitives/Callout";
+import { Header2 } from "~/components/primitives/Headers";
+import { Paragraph } from "~/components/primitives/Paragraph";
+
+/** Matches sonner's default toast width, same as the app's other toasts. */
+const TOAST_WIDTH = 356;
+
+/** More new wakes than this at once collapse into one summary toast. */
+export const WAKE_TOAST_MAX_INDIVIDUAL = 3;
+
+export type WatchWake = {
+ watchId: string;
+ chatId: string;
+ outcome: "fired" | "expired";
+ note: string;
+};
+
+function WakeToastUI({
+ t,
+ title,
+ message,
+ onOpenChat,
+}: {
+ t: string;
+ title: string;
+ message: string;
+ onOpenChat: () => void;
+}) {
+ return (
+ // Opaque base under the callout: a callout is translucent by design, and a
+ // toast has to read over whatever page is behind it.
+
+ );
+}
+
+function show(node: (t: string) => React.ReactElement, id: string) {
+ toast.custom((t) => node(t as string), {
+ // Manual dismissal only — see the file comment.
+ duration: Infinity,
+ // Keyed so a re-render or a duplicate poll can't stack the same wake twice.
+ id,
+ });
+}
+
+/**
+ * One persistent toast for a single wake. `onOpenChat` is given the chat the wake
+ * happened in — the toast is about that conversation, so it must open that one
+ * rather than whichever chat the panel had last.
+ */
+export function showWatchWakeToast(wake: WatchWake, onOpenChat: (chatId: string) => void) {
+ show(
+ (t) => (
+ onOpenChat(wake.chatId)}
+ />
+ ),
+ `watch-wake-${wake.watchId}`
+ );
+}
+
+/** One persistent toast standing in for a batch too large to narrate one by one. */
+export function showWatchWakesSummaryToast(count: number, onOpenChat: () => void) {
+ show(
+ (t) => (
+
+ ),
+ `watch-wakes-summary-${count}-${Date.now()}`
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/agent-badges.tsx b/apps/webapp/app/components/dashboard-agent/agent-badges.tsx
new file mode 100644
index 00000000000..a62a5e1be33
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/agent-badges.tsx
@@ -0,0 +1,192 @@
+/**
+ * One badge for every label the agent's cards put in a chip, and the matching
+ * status icons.
+ *
+ * Geometry and typography come from the `Badge` primitive's `small` variant, so
+ * a category chip, a confidence chip and a severity chip are the same box. Only
+ * colour and icon differ — that is the whole semantic distinction:
+ *
+ * - category / evidence type — neutral, no icon
+ * - confidence — leveled (high / medium / low)
+ * - severity, verdict — status colours
+ *
+ * The icon helpers exist so a status *line* can follow the same rule as the run
+ * status cells do (`runs/v3/TaskRunStatus`): the text keeps the default colour
+ * and the state is carried by a coloured icon.
+ */
+import {
+ CheckCircleIcon,
+ ExclamationCircleIcon,
+ ExclamationTriangleIcon,
+ InformationCircleIcon,
+ NoSymbolIcon,
+ QuestionMarkCircleIcon,
+} from "@heroicons/react/20/solid";
+import { Badge } from "~/components/primitives/Badge";
+import { cn } from "~/utils/cn";
+
+export type AgentTone = "neutral" | "success" | "warning" | "error";
+
+// Semantic tokens, not raw palette classes: those are tuned for the dark theme
+// only, and these tokens are what the theme layer remaps (see tailwind.css).
+const TONE_BADGE: Record = {
+ neutral: "border-border-bright text-text-dimmed",
+ success: "border-success/40 text-success",
+ warning: "border-warning/40 text-warning",
+ error: "border-error/40 text-error",
+};
+
+export const TONE_ICON_COLOR: Record = {
+ neutral: "text-text-dimmed",
+ success: "text-success",
+ warning: "text-warning",
+ error: "text-error",
+};
+
+type IconComponent = (props: { className?: string }) => JSX.Element;
+
+// Text case follows the Badge `small` convention used across the app:
+// Capitalized labels, never uppercase.
+export function AgentBadge({
+ tone = "neutral",
+ icon: Icon,
+ className,
+ children,
+}: {
+ tone?: AgentTone;
+ icon?: IconComponent;
+ className?: string;
+ children: React.ReactNode;
+}) {
+ return (
+ span]:flex [&>span]:items-center [&>span]:gap-1",
+ TONE_BADGE[tone],
+ className
+ )}
+ >
+ {Icon ? : null}
+ {children}
+
+ );
+}
+
+/** A failure category, an evidence type — anything that classifies, not grades. */
+export function CategoryBadge({
+ className,
+ children,
+}: {
+ className?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export type AgentConfidence = "high" | "medium" | "low";
+
+const CONFIDENCE_TONE: Record = {
+ high: "success",
+ medium: "warning",
+ low: "neutral",
+};
+
+const CONFIDENCE_ICON: Record = {
+ high: CheckCircleIcon,
+ medium: ExclamationTriangleIcon,
+ low: QuestionMarkCircleIcon,
+};
+
+const CONFIDENCE_LABEL: Record = {
+ high: "High confidence",
+ medium: "Medium confidence",
+ low: "Low confidence",
+};
+
+export function ConfidenceBadge({ confidence }: { confidence: AgentConfidence }) {
+ return (
+
+ {CONFIDENCE_LABEL[confidence]}
+
+ );
+}
+
+export type AgentSeverity = "info" | "warn" | "crit";
+
+const SEVERITY_TONE: Record = {
+ info: "neutral",
+ warn: "warning",
+ crit: "error",
+};
+
+const SEVERITY_ICON: Record = {
+ info: InformationCircleIcon,
+ warn: ExclamationTriangleIcon,
+ crit: ExclamationCircleIcon,
+};
+
+export function SeverityBadge({
+ severity,
+ children,
+}: {
+ severity: AgentSeverity;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export type AgentVerdict = "testing" | "validated" | "invalidated";
+
+const VERDICT_TONE: Record = {
+ testing: "neutral",
+ validated: "success",
+ invalidated: "neutral",
+};
+
+const VERDICT_ICON: Record = {
+ testing: undefined,
+ validated: CheckCircleIcon,
+ invalidated: NoSymbolIcon,
+};
+
+export function VerdictBadge({
+ verdict,
+ children,
+}: {
+ verdict: AgentVerdict;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * One evidence row: a fixed left column for the type badge, the rest for the
+ * text. Shared so the diagnosis and investigation cards align identically.
+ */
+export const EVIDENCE_ROW_CLASS = "grid grid-cols-[6.5rem_1fr] items-start gap-x-3";
+
+/** A coloured state icon for a status line whose text stays the default colour. */
+export function AgentStatusIcon({
+ tone,
+ icon: Icon,
+ className,
+}: {
+ tone: AgentTone;
+ icon: IconComponent;
+ className?: string;
+}) {
+ return ;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/chat-layout.test.ts b/apps/webapp/app/components/dashboard-agent/chat-layout.test.ts
new file mode 100644
index 00000000000..5c16d52f7c8
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/chat-layout.test.ts
@@ -0,0 +1,105 @@
+/**
+ * Keeps the chat layout library the only place transcript layout is decided.
+ *
+ * Source-level on purpose: the rule being enforced is "a consumer doesn't type
+ * spacing classes", which is a property of the source, not of the rendered DOM.
+ * Only the regions a consumer marks as transcript-level are checked — a panel
+ * header or an empty state above/below the transcript is not the library's
+ * business.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const DIR = __dirname;
+
+/** Files whose transcript regions must compose via chat-layout only. */
+const CONSUMERS = ["DashboardAgentMessages.tsx"];
+
+const LIBRARY = "chat-layout.tsx";
+
+const REGION = /#region chat-layout transcript([\s\S]*?)#endregion chat-layout transcript/g;
+
+/**
+ * Tailwind spacing utilities: padding, margin, gap and the `space-*` rhythm
+ * helpers. Matched only at a class-name boundary so `gap-2` is caught but
+ * `min-w-0`, `overflow-y-auto` and prose like "space-y" in a comment are not.
+ */
+const SPACING_CLASS =
+ /(?:^|[\s"'`])-?(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|gap-x|gap-y|space-x|space-y)-[\w./[\]%-]+/;
+
+function read(file: string): string {
+ return readFileSync(join(DIR, file), "utf8");
+}
+
+function transcriptRegions(source: string): string[] {
+ return [...source.matchAll(REGION)].map((match) => match[1]!);
+}
+
+describe("chat-layout enforcement", () => {
+ for (const consumer of CONSUMERS) {
+ describe(consumer, () => {
+ const source = read(consumer);
+ const regions = transcriptRegions(source);
+
+ it("marks its transcript-level code with a chat-layout region", () => {
+ expect(regions.length).toBeGreaterThan(0);
+ // A region that shrank to nothing would pass every other assertion.
+ expect(regions.join("").length).toBeGreaterThan(200);
+ });
+
+ it("imports its layout from the library", () => {
+ expect(source).toMatch(/from "\.{1,2}\/(?:\.\.\/)?chat-layout"/);
+ });
+
+ for (const [i, region] of regions.entries()) {
+ it(`writes no spacing class in transcript region ${i + 1}`, () => {
+ const offenders = region
+ .split("\n")
+ .filter((line) => SPACING_CLASS.test(line))
+ .map((line) => line.trim());
+ expect(
+ offenders,
+ `use a chat-layout micro-layout instead:\n${offenders.join("\n")}`
+ ).toEqual([]);
+ });
+ }
+ });
+ }
+
+ describe(LIBRARY, () => {
+ const source = read(LIBRARY);
+
+ it("is the single owner of the transcript's padding and rhythm", () => {
+ // Pinned so a change to the transcript's geometry is a change to this
+ // test, not a silent drift in one consumer.
+ expect(source).toContain('const TRANSCRIPT_INSET_X = "px-4"');
+ expect(source).toContain('const TRANSCRIPT_INSET_Y = "py-4"');
+ expect(source).toContain('const TURN_GAP = "space-y-4"');
+ expect(source).toContain('const TURN_BODY_GAP = "space-y-2"');
+ });
+
+ it("exports a component for every documented micro-layout", () => {
+ for (const name of [
+ "ChatTranscript",
+ "ChatTurn",
+ "ChatText",
+ "ChatCardSlot",
+ "ChatProgress",
+ "ChatPendingTool",
+ "ChatToolRow",
+ "ChatNote",
+ "ChatStatusLine",
+ "ChatWakeSlot",
+ "ChatActionsRow",
+ ]) {
+ expect(source, name).toContain(`export function ${name}(`);
+ }
+ });
+
+ it("documents the composition rules", () => {
+ expect(source).toContain("## Composition rules");
+ expect(source).toContain("ChatTranscript\n * ChatTurn*");
+ });
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/chat-layout.tsx b/apps/webapp/app/components/dashboard-agent/chat-layout.tsx
new file mode 100644
index 00000000000..264725afb45
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/chat-layout.tsx
@@ -0,0 +1,309 @@
+/**
+ * Chat layout — the one place transcript layout is decided.
+ *
+ * Every answer format the agent can produce gets a component here, and the chat
+ * composes those components instead of writing its own spacing. If you are
+ * building new chat content, you should not need to type a single padding,
+ * margin or `space-y-*` class: pick the micro-layout that matches the format,
+ * and the transcript's rules follow.
+ *
+ * ## Composition rules
+ *
+ * A transcript is:
+ *
+ * ChatTranscript
+ * ChatTurn* one row of the transcript
+ * one or more micro-layouts
+ *
+ * A turn body composes only these micro-layouts:
+ *
+ * - `ChatText` — markdown / plain text (assistant) or the user bubble
+ * - `ChatCardSlot` — a full-width block that owns its own internals: a rich
+ * card (diagnosis, investigation, report, chart), a
+ * callout, a chip row
+ * - `ChatProgress` — a spinner and one line of progress
+ * - `ChatPendingTool` — a tool call still in flight, as a compact pill
+ * - `ChatToolRow` — a tool-call row, optionally with progress under it
+ * - `ChatNote` — an inline system / interceptor note
+ * - `ChatWakeSlot` — an unprompted turn: its banner and the narration under
+ * it, kept together as one unit
+ * - `ChatStatusLine` — an icon and one line of status
+ * - `ChatActionsRow` — a row of buttons
+ *
+ * 1. **Spacing belongs to the library.** A consumer never writes `p-*`, `px-*`,
+ * `py-*`, `m-*`, `gap-*` or `space-y-*` at transcript level. If a new format
+ * needs spacing that isn't here, add a micro-layout — don't inline classes at
+ * the call site. A unit test (`chat-layout.test.ts`) enforces this for the
+ * regions the consumers mark as transcript-level.
+ * 2. **The inset has one owner.** `ChatTurn` owns the horizontal inset,
+ * `ChatTranscript` the vertical padding and the rhythm between turns. A
+ * micro-layout mounted inside a turn adds no inset of its own; the same
+ * micro-layout mounted anywhere else applies the inset itself (see
+ * `ChatInsetProvider`). That is why `ChatProgress` is aligned with the
+ * transcript wherever it is mounted, including under a card.
+ * 3. **Cards own their insides, the library owns their placement.** Anything
+ * within a card's border — its header strip, section padding, internal
+ * rhythm — is the card's business and must stay in the card. Anything about
+ * how the card sits in the transcript — full width, inset, distance to its
+ * neighbours — is `ChatCardSlot`'s, and a card must not set it.
+ * 4. **Role drives alignment and nothing else.** `role="user"` is right-aligned
+ * in the accent bubble; `role="assistant"` is left-aligned and full width.
+ */
+import type { Ref } from "react";
+import { createContext, Suspense, useContext } from "react";
+import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
+import { Spinner } from "~/components/primitives/Spinner";
+import { ChatBubble } from "~/components/runs/v3/ai/AIChatMessages";
+import { cn } from "~/utils/cn";
+
+/** The transcript's horizontal inset. Owned by `ChatTurn`. */
+const TRANSCRIPT_INSET_X = "px-4";
+/** The transcript's vertical padding. Owned by `ChatTranscript`. */
+const TRANSCRIPT_INSET_Y = "py-4";
+/** Rhythm between turns. Owned by `ChatTranscript`. */
+const TURN_GAP = "space-y-4";
+/** Rhythm between the micro-layouts inside one turn. */
+const TURN_BODY_GAP = "space-y-2";
+/** Gap inside a single-line row (icon to text, button to button). */
+const ROW_GAP = "gap-2";
+/** Gap inside a chip (icon to label). Tighter than a row — same as a watch chip. */
+const CHIP_GAP = "gap-1.5";
+/** Rhythm inside one unit — a banner and the text it introduces. */
+const UNIT_GAP = "space-y-1.5";
+
+const SCROLLER =
+ "flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control";
+
+export type ChatRole = "user" | "assistant";
+
+/**
+ * Whether the surrounding element already applied the transcript inset.
+ *
+ * Micro-layouts read this so the same component is correctly aligned both as a
+ * turn body and when it is mounted loose in the transcript (a card rendering
+ * `ChatProgress` under itself, for instance).
+ */
+const ChatInsetContext = createContext(false);
+
+/** Marks its subtree as already inset. Only layout components should use this. */
+function ChatInsetProvider({ inset, children }: { inset: boolean; children: React.ReactNode }) {
+ return {children};
+}
+
+/** The inset class a micro-layout must add itself, or undefined when nested. */
+function useInsetClass(): string | undefined {
+ return useContext(ChatInsetContext) ? undefined : TRANSCRIPT_INSET_X;
+}
+
+/**
+ * The scrolling column. One per chat panel: the vertical padding and the rhythm
+ * between turns live here, so no consumer sets either.
+ *
+ * `contentRef` is attached to the padded column *inside* the scroller — that is
+ * what `useTranscriptAutoScroll` expects (it walks up to find the scroller).
+ */
+export function ChatTranscript({
+ children,
+ contentRef,
+}: {
+ children: React.ReactNode;
+ contentRef?: Ref;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * One turn: the row that carries the transcript's horizontal inset and, for an
+ * assistant turn, the rhythm between the micro-layouts in its body.
+ *
+ * `bleed` drops the inset for content that is meant to span the panel edge to
+ * edge — the context banner is the only such case today.
+ */
+export function ChatTurn({
+ role = "assistant",
+ bleed = false,
+ children,
+}: {
+ role?: ChatRole;
+ bleed?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * A text body.
+ *
+ * The assistant variant is the panel's markdown path: the shared `ChatBubble`
+ * around a rendered-markdown container, with a plain-text fallback while the
+ * renderer loads. The user variant is the accent bubble.
+ */
+export function ChatText({ role = "assistant", text }: { role?: ChatRole; text: string }) {
+ if (role === "user") {
+ return (
+
+
{text}
+
+ );
+ }
+ return (
+
+
+ {text}}>
+ {text}
+
+
+
+ );
+}
+
+/**
+ * Where a block that owns its own internals mounts: a rich card, a callout, a
+ * chip row. Full width of the turn, no padding of its own — the card's border is
+ * the boundary between the library's rules and the card's own.
+ */
+export function ChatCardSlot({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
+
+/**
+ * The progress line: a spinner and one line of dimmed text, left-aligned.
+ *
+ * It always carries the transcript's inset — either from the turn it sits in, or
+ * by applying it itself when it is mounted loose (under a card, say). There is no
+ * way to mount it flush against the panel edge, which is the point.
+ *
+ * The text wraps, so the spinner is pinned to the FIRST line (`items-start`) and
+ * nudged down to sit optically centred against it — `items-center` floated it to
+ * the middle of a two-line message.
+ */
+export function ChatProgress({ children }: { children: React.ReactNode }) {
+ const insetClass = useInsetClass();
+ return (
+
+ {/* text-sm line box is 20px, the spinner 12px: 4px centres it on line one. */}
+
+ {children}
+
+ );
+}
+
+/**
+ * A tool call still in flight, as a compact pill: a spinner and one short phrase
+ * saying what the agent is doing.
+ *
+ * It replaces the tool row for the whole in-flight phase, so the transcript never
+ * shows a half-streamed blob of input JSON that then flips to a card. The pill is
+ * deliberately the smallest thing that fits the transcript's chip language (the
+ * watch chips are its sibling) — when the call lands, whatever the result renders
+ * as takes its place, and the jump is one line high.
+ */
+export function ChatPendingTool({ label }: { label: string }) {
+ const insetClass = useInsetClass();
+ return (
+
+
+
+ {label}
+
+
+ );
+}
+
+/**
+ * A tool-call row, and optionally a `ChatProgress` under it while the call is in
+ * flight. Nothing but the row's placement lives here — the row itself is the
+ * shared `ToolUseRow`.
+ */
+export function ChatToolRow({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
+
+/**
+ * An inline note in a voice that is not the agent's — a system aside, or the demo
+ * interceptor saying what would have happened.
+ */
+export function ChatNote({ children }: { children: React.ReactNode }) {
+ const insetClass = useInsetClass();
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * An icon and one line of status. The text keeps the default colour — the state
+ * is carried by the icon, the same rule the run status cells follow.
+ *
+ * `icon` is rendered as given (colour it at the call site, e.g. with
+ * `AgentStatusIcon`); `children` is the line, plus anything that belongs under
+ * it.
+ */
+export function ChatStatusLine({
+ icon,
+ children,
+}: {
+ icon?: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {icon}
+
{children}
+
+ );
+}
+
+/**
+ * An unprompted turn: the banner that says what woke the chat, then the body it
+ * introduces — tighter than the gap between two independent micro-layouts,
+ * because the two read as one thing.
+ */
+export function ChatWakeSlot({
+ banner,
+ children,
+}: {
+ banner: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {banner}
+ {children}
+
+ );
+}
+
+/** A row of buttons — a card's footer intents, a retry, a dismiss. */
+export function ChatActionsRow({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx
index 41b87ae3b89..40a27994010 100644
--- a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx
+++ b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx
@@ -5,6 +5,17 @@ import { cn } from "~/utils/cn";
type DashboardAgentContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
+ /**
+ * Open the panel with `text` already in play: sent as the first message of a
+ * new chat when nothing is open, or dropped into the composer of the chat
+ * that's already open (so an in-progress conversation is never hijacked).
+ */
+ openWith: (text: string) => void;
+ /**
+ * Watch wakes the user hasn't seen. Polled only while the panel is closed —
+ * with it open the chat itself is the notification, so this stays at 0.
+ */
+ unreadWakes: number;
};
const DashboardAgentContext = createContext(null);
@@ -23,15 +34,17 @@ export function DashboardAgentLauncher() {
return null;
}
- const { open, setOpen } = agent;
+ const { open, setOpen, unreadWakes } = agent;
+ // Only meaningful while closed: an open panel shows the wake itself.
+ const hasUnread = !open && unreadWakes > 0;
return (
setOpen(!open)}
className={cn(
- "flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs text-text-bright transition",
+ "relative flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs text-text-bright transition",
open
? "border-border-brighter bg-background-hover"
: "border-border-bright bg-background-bright hover:border-border-brighter"
@@ -43,6 +56,12 @@ export function DashboardAgentLauncher() {
)}
{open ? "Collapse" : "Chat"}
+ {hasUnread && (
+
+ )}
);
}
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..a44c3225aba
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx
@@ -0,0 +1,30 @@
+/**
+ * A chart block, rendered from canned rows.
+ *
+ * The real `AgentChart` fetches `/resources/metric` to turn a block's TRQL into
+ * rows. Demo mode does no network, so this hands the same `QueryResultsChart`
+ * the fixture rows directly. The frame (border, title strip, height) mirrors
+ * `AgentChart` so the card reads identically in the panel.
+ */
+import { QueryResultsChart } from "~/components/code/QueryResultsChart";
+import { demoChart } from "../fixtures/chart";
+
+export function DemoChartCard({ title = demoChart.title }: { title?: string }) {
+ 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..a68d5998b11
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx
@@ -0,0 +1,82 @@
+/**
+ * How an honoured intent appears in the transcript, plus the demo interceptor's
+ * inline outcome line.
+ *
+ * The bubble is the user's only record that the agent moved their screen, so it
+ * states the action in the past tense and shows the deep link it used. In demo
+ * mode the link is a button, not a `Link`: clicking it reports what *would* have
+ * happened and navigates nowhere.
+ *
+ * The outcome text keeps the default colour — honoured vs rejected is carried by
+ * the coloured icon, the same rule the run status cells follow.
+ */
+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 { ChatNote, ChatStatusLine } from "../../chat-layout";
+import type { DemoIntent } from "../fixtures/intents";
+
+/**
+ * A neutral inline note — the demo interceptor's voice, never the agent's.
+ * Unlabelled on purpose: fixture chats present as real ones for review.
+ *
+ * The note is a transcript-level format, so it lives in the chat layout library
+ * as `ChatNote`; this is the demo's name for it.
+ */
+export const DemoNote = ChatNote;
+
+export function DemoIntentBubble({
+ intent,
+ onIntercept,
+}: {
+ intent: DemoIntent;
+ /** Called instead of acting. The host renders the result as a `DemoNote`. */
+ onIntercept?: (message: string) => void;
+}) {
+ const rejected = !intent.executable;
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoInvestigationCard.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoInvestigationCard.tsx
new file mode 100644
index 00000000000..a228baf565b
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoInvestigationCard.tsx
@@ -0,0 +1,216 @@
+/**
+ * The investigation card — DEMO ONLY, and deliberately so.
+ *
+ * There is no `investigation` block in the view catalog yet (M5 owns it), so
+ * this renders the proposed payload from `../fixtures/investigation` instead of
+ * a validated block. It borrows `RunDiagnosisCard`'s anatomy on purpose —
+ * bordered card, header strip with badges, `Section` bodies, the same colour
+ * ramp — so a reviewer is judging the *content model* (collapsed verdict,
+ * expandable hypotheses, cited evidence) and not a new visual language.
+ *
+ * When M5 ships, this component's props are the payload contract: whatever the
+ * review here approves is what the block should carry.
+ */
+import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
+import type { Evidence } from "@internal/dashboard-agent-contracts";
+import { useState } from "react";
+import { Button } from "~/components/primitives/Buttons";
+import { Callout } from "~/components/primitives/Callout";
+import { Spinner } from "~/components/primitives/Spinner";
+import { ChatProgress } from "../../chat-layout";
+import {
+ CategoryBadge,
+ ConfidenceBadge,
+ EVIDENCE_ROW_CLASS,
+ SeverityBadge,
+ VerdictBadge,
+} from "../../agent-badges";
+import type {
+ DemoHypothesis,
+ DemoHypothesisVerdict,
+ DemoInvestigation,
+ DemoInvestigationSeverity,
+} from "../fixtures/investigation";
+
+const SEVERITY_LABELS: Record = {
+ info: "Info",
+ warn: "Degraded",
+ crit: "Critical",
+};
+
+const VERDICT_LABELS: Record = {
+ testing: "Testing",
+ validated: "Validated",
+ invalidated: "Ruled out",
+};
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
+
+/**
+ * One citation. The `trigger://` URI is shown verbatim as monospace text rather
+ * than a link: in the real card the host resolves it to a dashboard path, and
+ * showing the raw URI here is what lets the reviewer check that the agent cited
+ * something addressable at all.
+ */
+function EvidenceItem({ evidence, stacked }: { evidence: Evidence; stacked?: boolean }) {
+ return (
+
+ {/* `w-fit` is what keeps the badge its own size when it is a block child of
+ the stacked item — the Badge primitive is a grid, so it would otherwise
+ stretch the full width and read as a bar. */}
+ {evidence.kind}
+
: null}
+ {hypothesis.evidence.length > 0 ? (
+ // Stacked, not two-column: nested under the hypothesis's indent the
+ // content column would be too narrow for identifiers and excerpts. The
+ // wide gap is deliberate — stacked items have no column to separate them,
+ // so the space between them is the only thing that says "next citation".
+
+ {/* Its own truncating line — the badge row's right corner can't hold a
+ run id reliably at panel width (same rule as RunDiagnosisCard). */}
+ {investigation.runId ? (
+
{investigation.runId}
+ ) : null}
+
+
+
+
{investigation.title}
+
+
+
{investigation.headline}
+
+
+ {/* A fix is only ever shown for a concluded investigation. An
+ inconclusive one gets "What to check next" instead — never both. */}
+ {concluded && investigation.remediation ? (
+
+
+ {/* Progress lives outside the card, on the left — the same line the chat
+ uses for "Working…" and in-flight tools. `ChatProgress` carries the
+ transcript's alignment itself, so it lines up with the card above it
+ instead of hugging the card's border. */}
+ {inProgress ? {investigation.progress ?? "Working…"} : null}
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoReportCard.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoReportCard.tsx
new file mode 100644
index 00000000000..81b279fa8e5
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoReportCard.tsx
@@ -0,0 +1,433 @@
+/**
+ * The report card — DEMO ONLY.
+ *
+ * The real card is `ReportView`. This one renders a `ReportViewModel` (the real
+ * type) as a panel-width card so a design review can settle the layout question
+ * the markdown renderer can't answer: what a report looks like inside a 380px
+ * chat panel.
+ *
+ * It reuses the production semantics wherever they exist — the health message
+ * catalog for every string, the shared sections, metric row and sparkline from
+ * `report-sparkline.tsx` for the layout — so the card holds no report vocabulary
+ * and no layout of its own. Only the *formatting* is local, and only because the
+ * markdown renderer keeps its formatters private.
+ */
+import type {
+ Finding,
+ Metric,
+ ReportViewModel,
+ Unit,
+} from "~/presenters/v3/reports/report-view-model";
+import { healthMessages } from "~/presenters/v3/reports/health/health-messages";
+import {
+ FOOTER_WATCH_CODE,
+ FOOTER_WATCH_ONLY_CODE,
+ ReportBody,
+ ReportCard,
+ ReportFindingLine,
+ ReportFooterAction,
+ ReportFooterActionLink,
+ ReportFooterLine,
+ ReportFooterLink,
+ ReportFooterNote,
+ ReportHeaderLine,
+ ReportHeadline,
+ ReportMetricList,
+ ReportMetricRow,
+ ReportNoteBlock,
+ ReportProse,
+ ReportProvenance,
+ ReportSeverityIcon,
+ reportDelta,
+ reportFooterStyle,
+ type ReportFooterItem,
+} from "../../report-sparkline";
+
+// --- formatting -------------------------------------------------------------
+// Local copies of the markdown renderer's private formatters. Kept in sync by
+// eye for the demo; the real ReportView should share them properly (M2).
+
+function fmtDuration(ms: number): string {
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ const s = ms / 1000;
+ if (s < 60) return Number.isInteger(s) ? `${s}s` : `${s.toFixed(1)}s`;
+ const m = s / 60;
+ return Number.isInteger(m) ? `${m}m` : `${m.toFixed(1)}m`;
+}
+
+function fmtValue(value: number, unit: Unit): string {
+ switch (unit) {
+ case "ms":
+ return fmtDuration(value);
+ case "ratio":
+ return `${(value * 100).toFixed(1)}%`;
+ case "perMin":
+ return `${value > 0 ? "+" : value < 0 ? "−" : ""}${Math.abs(Math.round(value)).toLocaleString("en-US")}/min`;
+ case "count":
+ default:
+ return Math.round(value).toLocaleString("en-US");
+ }
+}
+
+function fmtCount(value: number): string {
+ return Math.round(value).toLocaleString("en-US");
+}
+
+/** Fill the `{token}` placeholders the message catalog leaves for the renderer. */
+function fillTokens(text: string, tokens: Record): string {
+ return text.replace(/\{(\w+)\}/g, (whole, key: string) =>
+ tokens[key] === undefined ? whole : String(tokens[key])
+ );
+}
+
+function metricTokens(vm: ReportViewModel, metric: Metric): Record {
+ return {
+ value: metric.annotation?.value ?? metric.value,
+ window: vm.windowMinutes,
+ limit: metric.breakdown?.limit ?? "",
+ };
+}
+
+function findingTokens(vm: ReportViewModel): Record {
+ const triggered = vm.metrics.find((m) => m.id === "triggered");
+ const throughput = vm.metrics.find((m) => m.id === "throughput");
+ const liveness = vm.metrics.find((m) => m.id === "liveness");
+ return {
+ mult: triggered?.delta?.mult ?? "",
+ rate: Math.round(throughput?.breakdown?.done ?? 0),
+ age: liveness ? fmtDuration(liveness.value) : "",
+ };
+}
+
+// --- pieces ----------------------------------------------------------------
+
+function MetricRow({
+ vm,
+ metric,
+ anomalyMinutes,
+ hero,
+}: {
+ vm: ReportViewModel;
+ metric: Metric;
+ anomalyMinutes?: number;
+ /** The metric that explains the finding: its annotation is spelled out inline. */
+ hero?: boolean;
+}) {
+ const annotation = metric.annotation
+ ? fillTokens(healthMessages.annotationMessage(metric.annotation.code), metricTokens(vm, metric))
+ : undefined;
+ const note = annotation
+ ? annotation
+ : metric.normal !== undefined
+ ? `normal ~${fmtValue(metric.normal, metric.unit)}`
+ : metric.series?.kind === "estimated"
+ ? "Estimated from a proxy signal, so read it as a shape, not a number."
+ : undefined;
+
+ const composite = metric.unit === "perMin" && metric.breakdown?.done !== undefined;
+
+ return (
+ fmtValue(value, metric.unit)}
+ />
+ );
+}
+
+/** A finding's evidence: the metric grid, then "why:" — who owns it, what it isn't. */
+function FindingBody({
+ vm,
+ finding,
+ tokens,
+}: {
+ vm: ReportViewModel;
+ finding: Finding;
+ tokens: Record;
+}) {
+ const metrics = finding.metricIds
+ .map((id) => vm.metrics.find((m) => m.id === id))
+ .filter((m): m is Metric => m !== undefined);
+
+ // The anomaly window describes the finding's *driving* metric — the first id,
+ // since degraded findings list theirs in causal order.
+ const anomalyMinutes = finding.anomalyWindow?.touchesEnd
+ ? finding.anomalyWindow.minutes
+ : undefined;
+
+ return (
+
+ {dismissedIds.length} dismissed — not offered again on this page.
+
+ ) : null}
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoWatchChips.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoWatchChips.tsx
new file mode 100644
index 00000000000..234774970fe
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoWatchChips.tsx
@@ -0,0 +1,98 @@
+/**
+ * The watch chip row. One chip per watch, its state carried by a coloured icon,
+ * with a cancel affordance on the active ones only.
+ *
+ * A chip has to answer "what is being watched, and is it still watching?" at a
+ * glance in a 380px panel — hence the short label plus a state icon, with the
+ * note and cadence in the title attribute rather than on screen. The label text
+ * keeps the default colour: only the icon is coloured, the same rule the run
+ * status cells follow.
+ */
+import { CheckCircleIcon, ClockIcon, NoSymbolIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import type { WatchStatus } from "@internal/dashboard-agent-contracts";
+import { Spinner } from "~/components/primitives/Spinner";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import { cn } from "~/utils/cn";
+import { type AgentTone, TONE_ICON_COLOR } from "../../agent-badges";
+import type { DemoWatch } from "../fixtures/watches";
+
+const STATUS_TONE: Record = {
+ active: "neutral",
+ fired: "success",
+ expired: "neutral",
+ cancelled: "neutral",
+};
+
+const STATUS_LABEL: Record = {
+ active: "watching",
+ fired: "fired",
+ expired: "expired",
+ cancelled: "cancelled",
+};
+
+function StatusIcon({ status }: { status: WatchStatus }) {
+ const className = cn("size-3.5 shrink-0", TONE_ICON_COLOR[STATUS_TONE[status]]);
+ switch (status) {
+ case "active":
+ // Same choice as an executing run: a spinner is the "still going" state.
+ return ;
+ case "fired":
+ return ;
+ case "expired":
+ return ;
+ case "cancelled":
+ return ;
+ }
+}
+
+export function DemoWatchChips({
+ watches,
+ onCancel,
+}: {
+ watches: DemoWatch[];
+ /** Demo interceptor. Never cancels anything — reports what would happen. */
+ onCancel?: (watch: DemoWatch) => void;
+}) {
+ if (watches.length === 0) return null;
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/demo/demo-chats.ts b/apps/webapp/app/components/dashboard-agent/demo/demo-chats.ts
new file mode 100644
index 00000000000..e0238bf9c04
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/demo-chats.ts
@@ -0,0 +1,1023 @@
+/**
+ * The demo conversation registry — every v1 case as a canned transcript.
+ *
+ * A `DemoChat` is a script: an ordered list of items rendered through the
+ * production renderers. Most items are real `UIMessage[]`; the rest are the
+ * demo-only cards that stand in for view blocks that don't exist yet
+ * (investigation, report) or that would otherwise need the network (chart).
+ *
+ * Nothing here is persisted, fetched, or sent. The state gallery
+ * (`/storybook/agent-ui`) is the only consumer; the panel shows real chats.
+ *
+ * One rule for every case: a demo chat is one coherent story, as close to a real
+ * conversation as fixtures allow. Variation matrices — the same card in four
+ * states, a banner across four page kinds — belong to the state gallery, never to
+ * a chat, where stacked variants read as a bug.
+ */
+import type { UIMessage } from "@ai-sdk/react";
+import type { ReportViewModel } from "~/presenters/v3/reports/report-view-model";
+import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
+import type { TurnActivity } from "../DashboardAgentMessages";
+import { DEMO_WORLD, demoId, demoInvestigationUri, demoReportUri } from "./ids";
+import {
+ assistantMessage,
+ demoChartBlock,
+ demoDegradedReport,
+ demoDiagnosisBlockFirstPass,
+ demoDiagnosisBlockRevised,
+ demoHealthyReport,
+ demoIntents,
+ demoInvestigationConcluded,
+ demoInvestigationDirtyCommit,
+ demoInvestigationInconclusive,
+ demoInvestigationStreamingRev0,
+ demoInvestigationStreamingRev1,
+ demoLegacyDiagnosisBlock,
+ demoPageContexts,
+ demoPromptSets,
+ demoShowCodeMarkdown,
+ demoWatchNarration,
+ demoWatches,
+ failedToolPart,
+ pendingToolPart,
+ reasoningPart,
+ renderViewPart,
+ sourceUrlPart,
+ streamingTextPart,
+ textPart,
+ toolPart,
+ userMessage,
+ type DemoIntent,
+ type DemoInvestigation,
+ type DemoWatch,
+} from "./fixtures";
+
+/** The flows the playbook is organised by. */
+export type DemoFlow = "investigate" | "navigation" | "prompts" | "watch" | "reports" | "base";
+
+export type DemoItem =
+ /** Real messages, rendered by the production message renderer. */
+ | { kind: "messages"; messages: UIMessage[] }
+ | { kind: "investigation"; investigation: DemoInvestigation; expanded?: boolean }
+ | { kind: "report"; report: ReportViewModel; sourceUri?: string }
+ | { kind: "chart"; title?: string }
+ | { kind: "intent"; intent: DemoIntent }
+ | { kind: "watches"; watches: DemoWatch[] }
+ | {
+ kind: "prompts";
+ prompts: SuggestedPrompt[];
+ context?: AgentPageContext;
+ dismissedIds?: string[];
+ }
+ /** A demo-voice aside explaining what the reviewer is looking at. */
+ | { kind: "note"; text: string }
+ /**
+ * A context banner rendered inline, full-bleed. No chat uses it now: a chat
+ * gets one banner — its own, at the top — and comparing banner variants is the
+ * gallery's job. Kept for a case that needs a banner mid-transcript.
+ */
+ | { kind: "banner"; projectSlug: string; environmentSlug: string; currentPage: string };
+
+export type DemoChat = {
+ /** Always `demo:`-prefixed. */
+ id: string;
+ /** History-list title. */
+ title: string;
+ flow: DemoFlow;
+ /** One line: what the reviewer should be looking at. */
+ summary: string;
+ items: DemoItem[];
+ /**
+ * What the turn is doing, or absent when nothing is in flight — the same
+ * `activity` prop the production message renderer takes.
+ */
+ activity?: TurnActivity;
+ /** Render the error row, with a retry affordance. */
+ error?: string;
+ /** Text the composer starts with. */
+ draft?: string;
+ /** Context banner for this chat. Defaults to the panel's real one. */
+ banner?: { projectSlug: string; environmentSlug: string; currentPage: string };
+ /** Watch chips shown under the banner, as the panel header would. */
+ headerWatches?: DemoWatch[];
+ /** Marks the transcript as replayed from the store rather than live. */
+ resumed?: boolean;
+ lastMessageAt: string;
+};
+
+// `currentPage` is the human label the banner shows (see `page-label.ts`), not
+// a path segment.
+const PROD_BANNER = {
+ projectSlug: "demo-storefront",
+ environmentSlug: "prod",
+ currentPage: "Runs",
+};
+
+// ---------------------------------------------------------------------------
+// Investigate
+// ---------------------------------------------------------------------------
+
+const investigateStreaming: DemoChat = {
+ id: demoId("investigate-streaming"),
+ title: "Why did this run fail?",
+ flow: "investigate",
+ summary:
+ "The investigation card mid-flight: two revisions of the same card, three hypotheses posed and testing, then one validated, one ruled out and one still open — with the narration in between.",
+ banner: { ...PROD_BANNER, currentPage: "Run detail" },
+ activity: "working",
+ lastMessageAt: "2026-07-27T10:14:11.000Z",
+ items: [
+ { kind: "messages", messages: [userMessage("inv-q", "Why did this run fail?")] },
+ {
+ kind: "messages",
+ messages: [
+ 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.`
+ ),
+ ]),
+ ],
+ },
+ { kind: "investigation", investigation: demoInvestigationStreamingRev0, expanded: true },
+ {
+ kind: "note",
+ text: "Revision 0 above, revision 1 below — the same investigation id. In the live panel the card is replaced in place; here both are shown so the change is visible.",
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("inv-step2", [
+ toolPart(
+ "query_runs",
+ { taskIdentifier: DEMO_WORLD.taskId, status: "failed", period: "1h" },
+ { matches: 41, fingerprints: { [DEMO_WORLD.errorFingerprint]: 41 } },
+ "query-runs-streaming-rev1"
+ ),
+ textPart(
+ `The rate limit is confirmed and the payload is ruled out — 41 runs in the last hour share one error fingerprint, while the same payload shape succeeded 2,104 times earlier today. Still open: whether \`${DEMO_WORLD.queue}\` is bursting into the provider faster than it allows.`
+ ),
+ ]),
+ ],
+ },
+ { kind: "investigation", investigation: demoInvestigationStreamingRev1, expanded: true },
+ ],
+};
+
+const investigateConcluded: DemoChat = {
+ id: demoId("investigate-concluded"),
+ title: "send-order-receipt failure",
+ flow: "investigate",
+ summary:
+ "A full post-incident turn: four tools, the conclusion narrated, the card open on four tested hypotheses with verdict chips and cited evidence, then a follow-up about whether the failed runs retry themselves.",
+ banner: { ...PROD_BANNER, currentPage: "Run detail" },
+ lastMessageAt: "2026-07-27T10:14:24.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [userMessage("inv-c-q", `Investigate why ${DEMO_WORLD.failedRunId} failed.`)],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("inv-c-tools", [
+ reasoningPart(
+ "Four candidates: the provider's rate limit, a bad payload, the retry schedule, and yesterday's deploy. Each one has a check that can rule it out, so run all four before writing a conclusion."
+ ),
+ toolPart(
+ "query_runs",
+ { fingerprint: DEMO_WORLD.errorFingerprint, period: "1h" },
+ { matches: 41, tasks: [DEMO_WORLD.taskId], firstSeen: "09:02", lastSeen: "10:11" },
+ "query-runs"
+ ),
+ toolPart(
+ "get_queue_health",
+ { queue: DEMO_WORLD.queue, period: "1h" },
+ { concurrency: 50, limit: 50, pinnedMinutes: 38, pending: 4_812 },
+ "get-queue-health-concluded"
+ ),
+ toolPart(
+ "list_deploys",
+ { period: "48h" },
+ { latest: DEMO_WORLD.deploymentVersion, deployedAt: "2026-07-26T14:11:00.000Z" },
+ "list-deploys-concluded"
+ ),
+ toolPart(
+ "read_source",
+ { path: DEMO_WORLD.sourcePath, sha: DEMO_WORLD.sourceSha },
+ { lines: "14-20", excerpt: "retry: { maxAttempts: 3, minTimeoutInMs: 1_000 }" },
+ "read-source"
+ ),
+ textPart(
+ `Two of the four hold up. The provider is rate limiting this key, and the retry config puts all three attempts inside the same limit window — so a run that hits the limit once is guaranteed to fail. The payload and yesterday's deploy are both ruled out.`
+ ),
+ ]),
+ ],
+ },
+ { kind: "investigation", investigation: demoInvestigationConcluded, expanded: true },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("inv-c-close", [
+ textPart(
+ `The fix is a config change, not a code fix — I haven't changed anything. Raising \`minTimeoutInMs\` to 30s with a factor of 2 spreads the three attempts across ~2 minutes, and capping \`${DEMO_WORLD.queue}\` at 20 stops the burst that trips the limit in the first place. The queue cap you can set from the dashboard without a deploy.`
+ ),
+ ]),
+ ],
+ },
+ {
+ kind: "messages",
+ messages: [
+ userMessage("inv-c-followup", "Do the 41 failed runs retry on their own once I fix this?"),
+ ],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("inv-c-followup-a", [
+ toolPart(
+ "query_runs",
+ { fingerprint: DEMO_WORLD.errorFingerprint, period: "1h", status: "failed" },
+ { matches: 41, retriesRemaining: 0, oldest: DEMO_WORLD.priorRunId },
+ "query-runs-followup"
+ ),
+ textPart(
+ `No — all 41 used their three attempts, so they're final. They'll need re-triggering after the config change; the oldest is \`${DEMO_WORLD.priorRunId}\`, from 09:02. Fix the retry config first, otherwise a bulk replay will hit the same limit and burn its attempts the same way.`
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const investigateInconclusive: DemoChat = {
+ id: demoId("investigate-inconclusive"),
+ title: "Intermittent upstream timeouts",
+ flow: "investigate",
+ summary:
+ "No cause found: What we know + What to check next, and deliberately no fix section. Two hypotheses ruled out with numbers, one still open, and a follow-up that refuses to advise on cancelling.",
+ banner: { ...PROD_BANNER, currentPage: "Run detail" },
+ lastMessageAt: "2026-07-27T09:41:38.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [userMessage("inv-i-q", `Why is ${DEMO_WORLD.slowRunId} taking so long?`)],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("inv-i-tools", [
+ toolPart(
+ "get_run_details",
+ { runId: DEMO_WORLD.slowRunId },
+ {
+ runId: DEMO_WORLD.slowRunId,
+ status: "EXECUTING",
+ attempt: 1,
+ durationMs: 1_452_000,
+ machine: "large-1x",
+ },
+ "get-run-details-slow"
+ ),
+ toolPart(
+ "get_span_details",
+ { runId: DEMO_WORLD.slowRunId, spanId: "span_demoe71f" },
+ { name: "aggregate", durationMs: 1_421_000, children: 0, events: 0 },
+ "get-span-details-slow"
+ ),
+ textPart(
+ `\`${DEMO_WORLD.slowTaskId}\` has been executing for 24 minutes against a p95 of 3 minutes, and 23m41s of that sits inside a single \`aggregate\` span with no children and no events. I checked memory and queue wait to rule out the two usual suspects, but I can't see inside that span.`
+ ),
+ ]),
+ ],
+ },
+ { kind: "investigation", investigation: demoInvestigationInconclusive, expanded: true },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("inv-i-close", [
+ textPart(
+ "I'd rather say I don't know than guess: the time is inside a span with no children, so there's nothing in the telemetry to attribute it to. Memory peaked at 38% and the run was dequeued 40ms after it was triggered, so it isn't starved and it isn't waiting — it's genuinely working, somewhere I can't see."
+ ),
+ ]),
+ ],
+ },
+ {
+ kind: "messages",
+ messages: [userMessage("inv-i-followup", "Should I kill it?")],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("inv-i-followup-a", [
+ textPart(
+ `Your call — I can't cancel runs. It's 8x its normal duration but still holding one of the 20 concurrency slots on \`${DEMO_WORLD.backlogQueue}\`, and nothing else is queued behind it, so letting it run costs you little. If you do cancel it, add a span around the aggregation step first, otherwise the next run tells you exactly as little as this one.`
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const investigateShowCode: DemoChat = {
+ id: demoId("investigate-show-code"),
+ title: "Show me the failing code",
+ flow: "investigate",
+ summary:
+ "Follow-up turn after a conclusion: a fenced diff citing file:line@sha, with an explicit 'I haven't applied anything'.",
+ banner: { ...PROD_BANNER, currentPage: "Run detail" },
+ lastMessageAt: "2026-07-27T10:16:02.000Z",
+ items: [
+ { kind: "investigation", investigation: demoInvestigationConcluded },
+ { kind: "messages", messages: [userMessage("code-q", "Show me the code change.")] },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("code-a", [
+ toolPart(
+ "read_source",
+ { path: DEMO_WORLD.sourcePath, sha: DEMO_WORLD.sourceSha, lines: "14-20" },
+ { sha: DEMO_WORLD.sourceSha, path: DEMO_WORLD.sourcePath },
+ "read-source-again"
+ ),
+ textPart(demoShowCodeMarkdown),
+ ]),
+ ],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("code-a2", [
+ textPart(
+ `This half needs a deploy. The other half — capping \`${DEMO_WORLD.queue}\` at 20 concurrency — is a dashboard setting, and it's the one that stops the burst, so it's worth doing first even if the deploy waits for your next release.`
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const investigateDirtyCommit: DemoChat = {
+ id: demoId("investigate-dirty-commit"),
+ title: "Failure after yesterday's deploy",
+ flow: "investigate",
+ summary:
+ "The same conclusion, hedged: the deploy was built from a dirty working tree, so source citations are the nearest repository snapshot — not the exact deployed code. Confidence drops to medium.",
+ banner: { ...PROD_BANNER, currentPage: "Run detail" },
+ lastMessageAt: "2026-07-27T10:18:40.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [userMessage("dirty-q", "Same question, but this deploy wasn't a clean build.")],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("dirty-tools", [
+ toolPart(
+ "list_deploys",
+ { version: DEMO_WORLD.deploymentVersion },
+ {
+ version: DEMO_WORLD.deploymentVersion,
+ gitSha: DEMO_WORLD.sourceSha,
+ dirty: true,
+ uncommittedFiles: 3,
+ },
+ "list-deploys-dirty"
+ ),
+ textPart(
+ `\`${DEMO_WORLD.deploymentVersion}\` was built from a working tree with 3 uncommitted files, so \`${DEMO_WORLD.sourcePath}\` as I can read it is the nearest snapshot — not provably the code that ran. The conclusion is the same, but I'm dropping confidence to medium and saying which parts of the evidence that affects.`
+ ),
+ ]),
+ ],
+ },
+ { kind: "investigation", investigation: demoInvestigationDirtyCommit, expanded: true },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("dirty-close", [
+ textPart(
+ "The run, span and error evidence comes from telemetry, so it stands regardless. Only the retry-config citation is hedged — if you deploy from a clean tree I can confirm that line exactly rather than approximately."
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Navigation
+// ---------------------------------------------------------------------------
+
+const navigateFilteredRuns: DemoChat = {
+ id: demoId("navigate-filtered-runs"),
+ title: "Failed runs in the last 24h",
+ flow: "navigation",
+ summary:
+ "The agent moved the user's screen and says so: a past-tense navigate bubble plus the deep link it used. Clicking the link is intercepted.",
+ banner: PROD_BANNER,
+ lastMessageAt: "2026-07-27T10:20:00.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [
+ userMessage(
+ "nav-q",
+ "Show me everything that failed in the last day for send-order-receipt."
+ ),
+ ],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("nav-a", [
+ toolPart(
+ "query_runs",
+ { taskIdentifier: DEMO_WORLD.taskId, status: "failed", period: "24h" },
+ {
+ matches: 41,
+ fingerprints: { [DEMO_WORLD.errorFingerprint]: 41 },
+ firstSeen: "09:02",
+ },
+ "query-runs-nav"
+ ),
+ textPart(
+ `41 runs of \`${DEMO_WORLD.taskId}\` failed in the last 24 hours, and they all share one error fingerprint — a 429 from the email provider. The first one was at 09:02 today; nothing failed before that. I've filtered your runs list to exactly those, so the deep link and what you see on screen agree.`
+ ),
+ ]),
+ ],
+ },
+ { kind: "intent", intent: demoIntents.navigateToFailedRuns },
+ { kind: "chart" },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("nav-a2", [
+ textPart(
+ `The spike starts at 09:00 and is confined to \`${DEMO_WORLD.taskId}\` — every other task on \`${DEMO_WORLD.queue}\` is flat through the same window. That shape matters: a provider-wide outage would have taken the other senders with it, so this is specific to this task's key and its retry schedule. Ask me to investigate one of those runs and I'll take it apart.`
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const navigateRejectedIntent: DemoChat = {
+ id: demoId("navigate-rejected-intent"),
+ title: "Take me to my deployments",
+ flow: "navigation",
+ summary:
+ "A reserved `propose_fix` intent is rejected out loud rather than silently ignored. This is the behaviour to review before write actions exist.",
+ banner: PROD_BANNER,
+ lastMessageAt: "2026-07-27T10:21:00.000Z",
+ items: [
+ { kind: "messages", messages: [userMessage("fix-q", "Just fix it for me.")] },
+ { kind: "intent", intent: demoIntents.proposeFix },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("fix-a", [
+ textPart(
+ `I can't change your project — I only read. Here's what needs changing and where: in \`${DEMO_WORLD.sourcePath}:18\`, raise \`minTimeoutInMs\` to 30s with a factor of 2 so the three attempts stop sharing one rate-limit window, and cap \`${DEMO_WORLD.queue}\` at 20 concurrency from the queue's settings page. The second one takes effect immediately, without a deploy. I'll walk through either if you want the diff.`
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Prompts
+// ---------------------------------------------------------------------------
+
+const promptsPageAware: DemoChat = {
+ id: demoId("prompts-page-aware"),
+ title: "What should I look at here?",
+ flow: "prompts",
+ summary:
+ "One page, one chip row: a run that failed a minute ago, the context line it was derived from, and the fresh-failure prompt promoted to the top slot.",
+ banner: { ...PROD_BANNER, currentPage: "Run detail" },
+ lastMessageAt: "2026-07-27T10:22:00.000Z",
+ items: [
+ {
+ kind: "prompts",
+ prompts: demoPromptSets.failedRun,
+ context: demoPageContexts.failedRun,
+ },
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Watch
+// ---------------------------------------------------------------------------
+
+const watchCreatedAndWake: DemoChat = {
+ id: demoId("watch-created-and-wake"),
+ title: "Tell me when the backlog drains",
+ flow: "watch",
+ summary:
+ "The full watch arc: created from a conversation with the cadence stated out loud, shown as a chip, speaking unprompted when it fires, then offering the next watch worth having.",
+ banner: { ...PROD_BANNER, currentPage: "Run detail" },
+ headerWatches: demoWatches.activeRow,
+ lastMessageAt: "2026-07-27T10:19:30.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [userMessage("watch-q", "Tell me when the retry finishes.")],
+ },
+ { kind: "intent", intent: demoIntents.watch },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("watch-created", [
+ textPart(
+ `Watching \`${DEMO_WORLD.failedRunId}\` — I'll check every minute for up to 2 hours and tell you the moment it settles, whichever way it goes. You'll also see it as a chip at the top of the panel until then, and you can cancel it from there. I only speak once per watch, so it won't repeat itself.`
+ ),
+ ]),
+ ],
+ },
+ { kind: "watches", watches: demoWatches.activeRow },
+ {
+ kind: "note",
+ text: "Everything below arrived on its own, minutes later — no user turn in between.",
+ },
+ {
+ kind: "messages",
+ messages: [assistantMessage("watch-wake", [textPart(demoWatchNarration.wake)])],
+ },
+ { kind: "watches", watches: [demoWatches.errorRecurrence] },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("watch-next", [
+ textPart(
+ `Two things you might want next: the 40 remaining runs from the 09:02 burst are still queued behind \`${DEMO_WORLD.queue}\`'s concurrency limit, and the rate-limit error itself is worth a watch for the next 12 hours in case the fix didn't take. Say the word for either.`
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const watchExpiryAndCancel: DemoChat = {
+ id: demoId("watch-expiry-and-cancel"),
+ title: "Watch for that error recurring",
+ flow: "watch",
+ summary:
+ "Three endings: expired having verified nothing happened, expired unable to verify at all, and cancelled from the chip.",
+ banner: { ...PROD_BANNER, currentPage: "Queues" },
+ headerWatches: demoWatches.row,
+ lastMessageAt: "2026-07-27T15:02:00.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("watch-row-intro", [
+ textPart(
+ `Four watches from this conversation, in every state they can end in. One is still live on \`${DEMO_WORLD.failedRunId}\`; the rest have finished, and each one said so exactly once — below, in the order they spoke.`
+ ),
+ ]),
+ ],
+ },
+ { kind: "watches", watches: demoWatches.row },
+ {
+ kind: "messages",
+ messages: [assistantMessage("watch-expiry", [textPart(demoWatchNarration.expiry)])],
+ },
+ {
+ kind: "note",
+ text: "The variant that matters most: the watch could not check its condition, and says so instead of implying an answer.",
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("watch-expiry-unverified", [
+ textPart(demoWatchNarration.expiryUnverified),
+ ]),
+ ],
+ },
+ {
+ kind: "messages",
+ messages: [assistantMessage("watch-cancelled", [textPart(demoWatchNarration.cancelled)])],
+ },
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Reports
+// ---------------------------------------------------------------------------
+
+const reportHealthy: DemoChat = {
+ id: demoId("report-healthy"),
+ title: "How is prod doing?",
+ flow: "reports",
+ summary:
+ "The health report with nothing wrong: three green statements, collapsed findings, 'nothing to do' footer.",
+ banner: { ...PROD_BANNER, currentPage: "Dashboard" },
+ lastMessageAt: "2026-07-27T10:15:00.000Z",
+ items: [
+ { kind: "messages", messages: [userMessage("rep-h-q", "How's prod doing?")] },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("rep-h-tool", [
+ toolPart(
+ "get_report",
+ { report: "health", period: "1h" },
+ {
+ title: "health",
+ severity: "ok",
+ windowMinutes: 60,
+ findings: ["flow", "execution", "liveness"],
+ },
+ "get-report-healthy"
+ ),
+ textPart(
+ "Short answer: prod is fine. Here's the last hour against your 7-day normal — flow, execution and telemetry freshness, each with the numbers behind the verdict."
+ ),
+ ]),
+ ],
+ },
+ {
+ kind: "report",
+ report: demoHealthyReport,
+ sourceUri: demoReportUri(DEMO_WORLD.reportKey),
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("rep-h-close", [
+ textPart(
+ "Nothing needs you. Runs are starting in 6.8s at p95 against a 7s normal, 0.4% of them failed where 0.5% is usual, and 34 are pending — all inside the band you've run at for the past week. Telemetry is 21 seconds behind, so this is a current picture rather than a stale one."
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const reportDegraded: DemoChat = {
+ id: demoId("report-degraded"),
+ title: "Is anything wrong right now?",
+ flow: "reports",
+ summary:
+ "Flow stalled at the env concurrency limit while execution stays healthy: causal chain, worst-queue attribution, 'not your code', and a two-entry footer including the do-nothing option.",
+ banner: { ...PROD_BANNER, currentPage: "Dashboard" },
+ lastMessageAt: "2026-07-27T10:15:00.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [userMessage("rep-d-q", "Something feels slow. What's going on?")],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("rep-d-tool", [
+ toolPart(
+ "get_report",
+ { report: "health", period: "1h" },
+ {
+ title: "health",
+ severity: "crit",
+ windowMinutes: 60,
+ worstQueue: DEMO_WORLD.queue,
+ pending: 4_812,
+ },
+ "get-report-degraded"
+ ),
+ textPart(
+ "It is slow, and it's a queueing problem rather than a code problem. Work is arriving faster than the environment's concurrency limit lets it start, so runs are waiting in front of execution instead of failing inside it."
+ ),
+ ]),
+ ],
+ },
+ {
+ kind: "report",
+ report: demoDegradedReport,
+ sourceUri: demoReportUri(DEMO_WORLD.reportKey),
+ },
+ { kind: "chart", title: "Pending runs, last 12h" },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("rep-d-close", [
+ textPart(
+ `Your code is fine — 0.6% of runs failed against a 0.5% normal and p95 duration hasn't moved. You've been pinned at the environment's concurrency ceiling of 50 for 38 of the last 60 minutes, 4,812 runs are pending, and \`${DEMO_WORLD.queue}\` accounts for 71% of them. Raising the limit clears it now; doing nothing clears it in about 27 minutes, once the 1,000-a-minute arrival spike drops back under the 820 a minute you're completing.`
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const docsAnswer: DemoChat = {
+ id: demoId("docs-answer"),
+ title: "How do I use batchTrigger?",
+ flow: "reports",
+ summary:
+ "A how-does-it-work question answered from the docs, with source links under the answer and no invented API.",
+ banner: PROD_BANNER,
+ lastMessageAt: "2026-07-27T10:24:00.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [userMessage("docs-q", "How do retries actually work? Is the delay exponential?")],
+ },
+ {
+ kind: "messages",
+ messages: [
+ 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"),
+ ]),
+ ],
+ },
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Base states
+// ---------------------------------------------------------------------------
+
+const baseStreaming: DemoChat = {
+ id: demoId("base-streaming"),
+ title: "Summarize today's failures",
+ flow: "base",
+ summary:
+ "A partial assistant message (text part still streaming) with the Thinking row underneath.",
+ banner: PROD_BANNER,
+ activity: "working",
+ lastMessageAt: "2026-07-27T10:25:00.000Z",
+ items: [
+ { kind: "messages", messages: [userMessage("stream-q", "What's failing right now?")] },
+ {
+ kind: "messages",
+ messages: [
+ 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"
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const baseToolInFlight: DemoChat = {
+ id: demoId("base-tool-in-flight"),
+ title: "How deep is the email queue?",
+ flow: "base",
+ summary:
+ "A pending pill for the call still in flight, under a finished tool row. Click the finished row to expand its input/output.",
+ banner: PROD_BANNER,
+ activity: "working",
+ lastMessageAt: "2026-07-27T10:26:00.000Z",
+ items: [
+ { kind: "messages", messages: [userMessage("tool-q", "Check the queue depth for me.")] },
+ {
+ kind: "messages",
+ messages: [
+ 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.`
+ ),
+ ]),
+ ],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("tool-a", [
+ toolPart(
+ "run_query",
+ { query: "SELECT count() FROM task_runs WHERE status = 'PENDING'" },
+ { rows: [{ "count()": 4812 }] },
+ "run-query-done"
+ ),
+ // A real tool name, so the pending pill shows its real phrase rather
+ // than the unknown-tool fallback.
+ pendingToolPart(
+ "get_queue",
+ { queue: DEMO_WORLD.queue, period: "1h" },
+ "get-queue-pending"
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const baseErrorRetry: DemoChat = {
+ id: demoId("base-error-retry"),
+ title: "List yesterday's runs",
+ flow: "base",
+ summary:
+ "A turn that failed: the failed tool row, the error row the panel renders, and a retry affordance.",
+ banner: PROD_BANNER,
+ error: "The chat stopped unexpectedly. Nothing was saved for this turn.",
+ lastMessageAt: "2026-07-27T10:27:00.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [userMessage("err-q", "Chart failures by task for the last week.")],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("err-a", [
+ failedToolPart(
+ "run_query",
+ { query: "SELECT task_identifier, count() FROM task_runs", period: "7d" },
+ "query timed out after 30s",
+ "run-query-failed"
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const baseResumed: DemoChat = {
+ id: demoId("base-resumed"),
+ title: "Queue health over time",
+ flow: "base",
+ summary:
+ "A transcript replayed from the store, including one pre-envelope block that must still render (and can never be revised).",
+ banner: PROD_BANNER,
+ resumed: true,
+ lastMessageAt: "2026-07-06T14:02:00.000Z",
+ items: [
+ { kind: "messages", messages: [userMessage("res-q", "Did this happen last month too?")] },
+ {
+ kind: "messages",
+ messages: [
+ 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.`
+ ),
+ // Two revisions of the same block plus one legacy block with no
+ // envelope: the renderer keeps revision 1 and renders the legacy card
+ // in transcript order.
+ renderViewPart(
+ [demoDiagnosisBlockFirstPass, demoDiagnosisBlockRevised, demoLegacyDiagnosisBlock],
+ "render-view-resumed"
+ ),
+ ]),
+ ],
+ },
+ {
+ kind: "note",
+ text: "The render_view part above carried three blocks: revisions 0 and 1 of one diagnosis (collapsed latest-wins) and one envelope-less block from an older transcript.",
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("res-a2", [
+ renderViewPart([demoChartBlock], "render-view-resumed-chart"),
+ textPart(
+ "The chart block runs live against your current environment, so it may be empty here."
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const baseComposerDraft: DemoChat = {
+ id: demoId("base-composer-draft"),
+ title: "Draft in the composer",
+ flow: "base",
+ summary:
+ "A question half typed and left there: the conversation is still empty (suggested prompts on screen) and the composer holds the unsent draft, cursor mid-word.",
+ banner: { ...PROD_BANNER, currentPage: "Run detail" },
+ // Deliberately unfinished mid-word: the state being reviewed is a draft, not a
+ // prefill, so it has to look like someone stopped typing.
+ draft: "why did the send-order-receipt run from last nig",
+ lastMessageAt: "2026-07-27T10:28:00.000Z",
+ // Empty on purpose: an unsent draft means nothing has been said yet, so the
+ // transcript is the first-open prompt panel.
+ items: [],
+};
+
+const basePageContext: DemoChat = {
+ id: demoId("base-page-context"),
+ title: "Which page am I on?",
+ flow: "base",
+ summary:
+ "The agent answers from page context alone: it names the page, project and environment shown in the banner, says what it can already see there, and offers the next step.",
+ banner: PROD_BANNER,
+ lastMessageAt: "2026-07-27T10:29:00.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [userMessage("ctx-q", "Which page am I on?")],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("ctx-a", [
+ textPart(
+ `You're on the **Runs** list of \`demo-storefront\`, in \`prod\` — the same three things the bar at the top of this panel shows. I get that with every message, so I don't have to ask where you are.
+
+From here I can see the list you're looking at: 41 runs of \`${DEMO_WORLD.taskId}\` failed in the last 24 hours, all with the same 429 from the email provider, and \`${DEMO_WORLD.failedRunId}\` is the most recent. Want me to take that one apart?`
+ ),
+ ]),
+ ],
+ },
+ {
+ kind: "messages",
+ messages: [userMessage("ctx-followup", "Does that change if I navigate somewhere else?")],
+ },
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("ctx-followup-a", [
+ textPart(
+ `Yes — the page is read fresh on every message, so if you open a run, or switch to \`staging\`, my next answer is about that page and that environment. Nothing carries over silently: if I'm still talking about \`${DEMO_WORLD.failedRunId}\` after you've moved, it's because you asked about it, not because I didn't notice.`
+ ),
+ ]),
+ ],
+ },
+ ],
+};
+
+const baseInvestigationDeepLink: DemoChat = {
+ id: demoId("base-investigation-uri"),
+ title: "Follow-up on the investigation",
+ flow: "base",
+ summary:
+ "An investigation cited by URI — the shape a shared or resumed investigation link takes.",
+ banner: PROD_BANNER,
+ lastMessageAt: "2026-07-27T10:30:00.000Z",
+ items: [
+ {
+ kind: "messages",
+ messages: [
+ assistantMessage("uri-a", [
+ textPart(
+ `The full investigation is at \`${demoInvestigationUri(demoInvestigationConcluded.investigationId)}\` — that id is stable, so asking me about it again resumes the same card rather than starting over. It covers four hypotheses on \`${DEMO_WORLD.failedRunId}\`: the provider's rate limit and the retry window held up, the payload and yesterday's deploy were ruled out. I've put you on the run itself, since that's where the evidence points.`
+ ),
+ ]),
+ ],
+ },
+ { kind: "intent", intent: demoIntents.navigateToRun },
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Registry
+// ---------------------------------------------------------------------------
+
+export const demoChats: DemoChat[] = [
+ investigateStreaming,
+ investigateConcluded,
+ investigateInconclusive,
+ investigateShowCode,
+ investigateDirtyCommit,
+ navigateFilteredRuns,
+ navigateRejectedIntent,
+ promptsPageAware,
+ watchCreatedAndWake,
+ watchExpiryAndCancel,
+ reportHealthy,
+ reportDegraded,
+ docsAnswer,
+ baseStreaming,
+ baseToolInFlight,
+ baseErrorRetry,
+ baseResumed,
+ baseComposerDraft,
+ basePageContext,
+ baseInvestigationDeepLink,
+];
+
+export function demoChatById(id: string): DemoChat | undefined {
+ return demoChats.find((chat) => chat.id === id);
+}
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..7e2d42de942
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts
@@ -0,0 +1,391 @@
+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 { demoChats } from "./demo-chats";
+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")
+);
+
+/** Every import specifier in a file, from both `import` and `export … from`. */
+function importSpecifiers(source: string): string[] {
+ return [...source.matchAll(/(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g)].map(
+ (match) => match[1]!
+ );
+}
+
+describe("demo ids", () => {
+ it("namespaces every chat id", () => {
+ for (const chat of demoChats) {
+ expect(chat.id.startsWith(DEMO_ID_PREFIX), chat.id).toBe(true);
+ }
+ });
+
+ it("has no duplicate chat ids", () => {
+ const ids = demoChats.map((chat) => chat.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("namespaces every message id", () => {
+ for (const chat of demoChats) {
+ for (const item of chat.items) {
+ if (item.kind !== "messages") continue;
+ for (const message of item.messages) {
+ expect(message.id.startsWith(DEMO_ID_PREFIX), message.id).toBe(true);
+ }
+ }
+ }
+ });
+
+ 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", () => {
+ // The sha is demo-marked, so it isn't pure hex — shape is what matters.
+ 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("demo coverage", () => {
+ it("covers every v1 flow", () => {
+ const flows = new Set(demoChats.map((chat) => chat.flow));
+ expect(flows).toEqual(
+ new Set(["investigate", "navigation", "prompts", "watch", "reports", "base"])
+ );
+ });
+
+ it("covers the base panel states", () => {
+ const ids = demoChats.map((chat) => chat.id);
+ for (const suffix of [
+ "base-streaming",
+ "base-tool-in-flight",
+ "base-error-retry",
+ "base-resumed",
+ "base-composer-draft",
+ "base-page-context",
+ ]) {
+ expect(ids).toContain(`${DEMO_ID_PREFIX}${suffix}`);
+ }
+ });
+
+ it("keeps the draft case an unsent draft over an empty conversation", () => {
+ // The first-open prompt panel and the composer draft are the same case: the
+ // transcript has to be empty for the panel to show, and the draft has to be
+ // there for the case to have a point.
+ const draftChat = demoChats.find((chat) => chat.id === `${DEMO_ID_PREFIX}base-composer-draft`);
+ expect(draftChat?.draft?.length ?? 0).toBeGreaterThan(0);
+ expect(draftChat?.items).toEqual([]);
+ });
+
+ it("keeps every chat to one story rather than a variant matrix", () => {
+ // Stacked variants of the same thing read as a bug in a conversation; they
+ // belong to the state gallery. One banner (the chat's own) and at most one
+ // prompt row per chat.
+ for (const chat of demoChats) {
+ const count = (kind: string) => chat.items.filter((item) => item.kind === kind).length;
+ expect(count("banner"), chat.id).toBe(0);
+ expect(count("prompts"), chat.id).toBeLessThanOrEqual(1);
+ }
+ });
+
+ it("answers the page-context question with a real exchange", () => {
+ const chat = demoChats.find((c) => c.id === `${DEMO_ID_PREFIX}base-page-context`);
+ const messages = (chat?.items ?? []).flatMap((item) =>
+ item.kind === "messages" ? item.messages : []
+ );
+ expect(messages.some((message) => message.role === "user")).toBe(true);
+ expect(messages.some((message) => message.role === "assistant")).toBe(true);
+ });
+
+ it("gives every chat a playbook summary and a natural title", () => {
+ for (const chat of demoChats) {
+ expect(chat.summary.length, chat.id).toBeGreaterThan(20);
+ // Titles read like real chats — identity lives in the demo: id, never the label.
+ expect(chat.title.includes("Demo"), chat.title).toBe(false);
+ expect(chat.title.length, chat.id).toBeGreaterThan(0);
+ }
+ });
+});
+
+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..38ae439248e
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/blocks.ts
@@ -0,0 +1,133 @@
+/**
+ * View-block fixtures — typed against the contracts package's own schemas.
+ *
+ * The enveloped fixtures are typed `EnvelopedViewBlock` (the strict, emit-side
+ * type) so the compiler proves the envelope is present and complete. The one
+ * legacy fixture is typed `ViewBlock` (the lenient, read-side type) and
+ * deliberately carries no envelope, so the mockup also covers the
+ * pre-envelope-transcript path the renderer must support forever.
+ */
+import {
+ VIEW_BLOCK_VERSION,
+ type EnvelopedChartBlock,
+ type EnvelopedDiagnosisBlock,
+ type ViewBlock,
+} from "@internal/dashboard-agent-contracts";
+import { demoId, DEMO_WORLD } from "../ids";
+
+const envelope = (id: string, revision = 0) => ({
+ id: demoId(id),
+ revision,
+ version: VIEW_BLOCK_VERSION,
+});
+
+/**
+ * The failure card as it lands on the first pass: a plausible cause, medium
+ * confidence, thin evidence.
+ */
+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.",
+ ],
+};
+
+/**
+ * The same block, re-emitted with better information. Same `id`, higher
+ * `revision` — the renderer collapses it latest-wins, which is the behaviour the
+ * streaming-card case is there to show.
+ */
+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",
+ },
+ ],
+};
+
+/** A chart block, exactly as the agent would emit one. */
+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",
+};
+
+/**
+ * A block replayed from a transcript written before the envelope existed: no
+ * `id`, no `revision`, no `version`. It must still parse and still render, and
+ * it can never be revised — that's the frozen rule this fixture pins.
+ */
+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."],
+};
+
+/** Every block fixture, for the schema test and the storybook gallery. */
+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..9e116d56f0b
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/chart.ts
@@ -0,0 +1,63 @@
+/**
+ * Canned chart data.
+ *
+ * The real `AgentChart` renders a `chart` block by POSTing its TRQL to
+ * `/resources/metric` and feeding the rows into `QueryResultsChart`. Demo mode
+ * must not talk to a resource route (and on a fresh local database the result
+ * would be an empty chart anyway), so the demo card skips the fetch and hands
+ * `QueryResultsChart` these rows directly — same chart component, same config
+ * shape, no network. The `chart` block fixture in `blocks.ts` still carries the
+ * query the agent would have emitted, so both halves stay reviewable.
+ */
+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],
+};
+
+// 12 hourly buckets ending at the fixture "now", so the x-axis reads as the
+// last half day.
+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..aeb836b30c6
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts
@@ -0,0 +1,12 @@
+/**
+ * Every fixture in one place. The demo conversations and (later) the storybook
+ * gallery both import from here, so there is exactly one source of dummy data.
+ */
+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..84e0297d1d5
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts
@@ -0,0 +1,82 @@
+/**
+ * Intent fixtures. An intent is a *request* to the host — emitting one is never
+ * an action — so each fixture pairs the intent with the sentence the panel shows
+ * once the host has honoured it ("Opened runs filtered to failed · last 24h").
+ * That sentence is what the design review is judging: the user has to be able to
+ * tell, after the fact, what the agent just did to their screen.
+ *
+ * Nothing here is honoured: the gallery renders the outcome inline instead of
+ * navigating.
+ */
+import { isExecutableIntent, type AgentIntent } from "@internal/dashboard-agent-contracts";
+import { DEMO_WORLD, demoRunUri } from "../ids";
+import { demoBacklogDrainWatch } from "./watches";
+
+export type DemoIntent = {
+ intent: AgentIntent;
+ /** Past-tense summary of what the host did, as shown in the transcript. */
+ outcome: string;
+ /** The deep link the bubble renders, as the user-visible path. */
+ deepLinkLabel?: string;
+ /** True when a host may act on this intent today (`propose_fix` may not). */
+ executable: boolean;
+};
+
+const demoIntent = (intent: AgentIntent, outcome: string, deepLinkLabel?: string): DemoIntent => ({
+ intent,
+ outcome,
+ deepLinkLabel,
+ executable: isExecutableIntent(intent),
+});
+
+/** Navigate to the runs list with filters applied — the common navigate case. */
+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"
+);
+
+/** Navigate straight to one run. */
+export const demoNavigateToRun = demoIntent(
+ { kind: "navigate", target: demoRunUri(DEMO_WORLD.failedRunId) },
+ `Opened ${DEMO_WORLD.failedRunId}`,
+ `/runs/${DEMO_WORLD.failedRunId}`
+);
+
+/** Hand a follow-up question back into the conversation. */
+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"
+);
+
+/** Start a watch. */
+export const demoWatchIntent = demoIntent(
+ { kind: "watch", spec: demoBacklogDrainWatch.spec },
+ `Watching ${DEMO_WORLD.backlogQueue} · checking every 5 min for up to 6h`
+);
+
+/**
+ * RESERVED until write actions ship. Kept as a fixture so the mockup shows the
+ * host *rejecting* it explicitly rather than silently ignoring it — that
+ * rejection is the behaviour we want reviewed now, while it's still cheap.
+ */
+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..47d417e81c7
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/investigation.ts
@@ -0,0 +1,426 @@
+/**
+ * Investigation fixtures — and, until M5 lands, the *proposed shape* of the
+ * investigation payload itself.
+ *
+ * There is no `investigation` member in the view-block catalog yet: the
+ * contracts package freezes only its identity rule (a block whose `id` is the
+ * `investigationId` and whose `revision` climbs as the investigation
+ * progresses). The payload is M5's to define, and the design review of this
+ * mockup is what freezes it. So the types below are written as the real payload
+ * we intend to ship, not as throwaway props: when M5 adds the block, this file's
+ * `DemoInvestigation` should be liftable into `blocks.ts` more or less verbatim,
+ * with `Evidence` and the envelope already in their final form.
+ *
+ * The card that renders these lives in `../components/DemoInvestigationCard`.
+ */
+import type { Evidence } from "@internal/dashboard-agent-contracts";
+import {
+ DEMO_WORLD,
+ demoDeploymentUri,
+ demoErrorUri,
+ demoId,
+ demoQueueUri,
+ demoRunUri,
+ demoSourceUri,
+ demoSpanUri,
+} from "../ids";
+
+/**
+ * Where a hypothesis stands. `testing` is a live state the card shows while the
+ * investigation is still running; the two verdicts are terminal.
+ */
+export type DemoHypothesisVerdict = "testing" | "validated" | "invalidated";
+
+export type DemoHypothesis = {
+ id: string;
+ /** The claim, as a falsifiable sentence. */
+ statement: string;
+ verdict: DemoHypothesisVerdict;
+ /** Why the verdict — one sentence. Absent while `testing`. */
+ finding?: string;
+ /** The citations that settled it. */
+ evidence: Evidence[];
+};
+
+/**
+ * `concluded` — there is a cause and a fix.
+ * `inconclusive` — the evidence ran out; the card must not invent a fix.
+ * `in_progress` — still testing hypotheses.
+ */
+export type DemoInvestigationOutcome = "in_progress" | "concluded" | "inconclusive";
+
+/**
+ * How bad it is. Mirrors the report view model's severity ladder minus `ok` —
+ * an investigation only exists because something was wrong.
+ */
+export type DemoInvestigationSeverity = "info" | "warn" | "crit";
+
+/**
+ * A caveat qualifies the whole card. `dirty_commit` is the one v1 case: the
+ * source we read is the nearest repository snapshot, not provably the code that
+ * was deployed, so every source citation on the card inherits the hedge.
+ */
+export type DemoInvestigationCaveat = {
+ kind: "dirty_commit";
+ message: string;
+};
+
+export type DemoInvestigation = {
+ /** Doubles as the block `id` under the frozen identity rule. */
+ investigationId: string;
+ /** Which revision of the card this is. Climbs; the renderer keeps the highest. */
+ revision: number;
+ outcome: DemoInvestigationOutcome;
+ severity: DemoInvestigationSeverity;
+ confidence: "high" | "medium" | "low";
+ /** The run (or other resource) under investigation. */
+ runId?: string;
+ /** Short headline, e.g. "send-order-receipt is failing on every retry". */
+ title: string;
+ /**
+ * The collapsed view's first block. Concluded: severity + cause in one or two
+ * sentences. Inconclusive: what we established instead ("What we know").
+ */
+ headline: string;
+ /** Remediation prose. Present only when `outcome === "concluded"`. */
+ remediation?: string;
+ /** Present only when `outcome === "inconclusive"` — never alongside a fix. */
+ checkNext?: string[];
+ /** What the agent is doing right now. Present while `in_progress`. */
+ progress?: string;
+ hypotheses: DemoHypothesis[];
+ /** Citations that back the headline itself, beyond the per-hypothesis ones. */
+ 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",
+};
+
+// ---------------------------------------------------------------------------
+// (a) Streaming — the card mid-flight, hypotheses still being tested.
+// ---------------------------------------------------------------------------
+
+/** Revision 0: hypotheses posed, nothing settled yet. */
+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",
+};
+
+/** Revision 1: one hypothesis settled, the other still open. Same id. */
+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",
+};
+
+// ---------------------------------------------------------------------------
+// (b) Concluded — cause + fix, two settled hypotheses, citations.
+// ---------------------------------------------------------------------------
+
+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",
+};
+
+// ---------------------------------------------------------------------------
+// (c) Inconclusive — "What we know" + "What to check next", no fix.
+// ---------------------------------------------------------------------------
+
+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",
+};
+
+// ---------------------------------------------------------------------------
+// (e) Dirty-commit caveat — the same concluded card, hedged.
+// ---------------------------------------------------------------------------
+
+/**
+ * The deployed version was built from a working tree with uncommitted changes,
+ * so the file we read is the nearest repository snapshot rather than provably
+ * the deployed code. The wording is the point of this fixture: it hedges the
+ * source citation without hedging the evidence that came from telemetry.
+ */
+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 = {
+ streamingRev0: demoInvestigationStreamingRev0,
+ streamingRev1: demoInvestigationStreamingRev1,
+ concluded: demoInvestigationConcluded,
+ inconclusive: demoInvestigationInconclusive,
+ dirtyCommit: demoInvestigationDirtyCommit,
+} as const;
+
+/**
+ * The show-code follow-up: a fenced diff citing `file:line@sha`. Kept next to
+ * the investigation fixtures because it is the same turn's continuation — the
+ * user asks "show me the code", the agent answers with the patch it would write.
+ */
+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..104275da649
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/messages.ts
@@ -0,0 +1,94 @@
+/**
+ * Builders for the fixture transcripts. Everything here produces real
+ * `UIMessage` values — the exact shape `DashboardAgentMessages` receives from
+ * `useChat` — so a fixture conversation renders through the production message
+ * renderer with no adapter in between.
+ *
+ * Message ids are demo-namespaced. They are only React keys here, but a stray
+ * `msg_…`-shaped id in a fixture would be indistinguishable from a real one in
+ * a screenshot or a bug report, so they carry the marker too.
+ */
+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}`);
+}
+
+/** A user turn. */
+export function userMessage(name: string, text: string): UIMessage {
+ return { id: demoMessageId(name), role: "user", parts: [{ type: "text", text }] };
+}
+
+/** An assistant turn assembled from the parts below. */
+export function assistantMessage(name: string, parts: Part[]): UIMessage {
+ return { id: demoMessageId(name), role: "assistant", parts };
+}
+
+/** Markdown text. Rendered through Streamdown by the shared renderer. */
+export function textPart(text: string): Part {
+ return { type: "text", text, state: "done" };
+}
+
+/** A text part still arriving — the streaming case. */
+export function streamingTextPart(text: string): Part {
+ return { type: "text", text, state: "streaming" };
+}
+
+export function reasoningPart(text: string): Part {
+ return { type: "reasoning", text, state: "done" };
+}
+
+/** A finished tool call: input + output, rendered as an expandable tool row. */
+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;
+}
+
+/** A tool call still in flight — the "calling..." row. */
+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;
+}
+
+/** A tool call that failed — the "error: …" row. */
+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;
+}
+
+/**
+ * A completed `render_view` call. Its `{ blocks }` output is what the panel
+ * feeds to the view catalog, so this is the only way a fixture puts a real
+ * catalog card on screen.
+ */
+export function renderViewPart(blocks: ViewBlock[], callName?: string): Part {
+ return toolPart("render_view", { blocks }, { blocks }, callName ?? "render-view");
+}
+
+/** A docs citation, rendered as a source link under the answer. */
+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/page-context.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts
new file mode 100644
index 00000000000..acf72b84508
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts
@@ -0,0 +1,287 @@
+/**
+ * Page-context and suggested-prompt fixtures.
+ *
+ * M4 owns the prompt *registry* (the resolver that turns a page context into
+ * chips). It doesn't exist yet, so this file supplies both halves of the
+ * contract it will sit between: one `AgentPageContext` per page kind (with the
+ * signals that make each page interesting), and the chip set a good resolver
+ * should produce for it — including the promoted slot and the dismissed state.
+ *
+ * When the registry lands, these contexts become its test inputs and
+ * `demoPromptSets` becomes the expected output, so nothing here is throwaway.
+ */
+import type {
+ AgentPageContext,
+ AgentPageSignal,
+ SuggestedPrompt,
+} from "@internal/dashboard-agent-contracts";
+import { SUGGESTED_PROMPT_CAP } from "@internal/dashboard-agent-contracts";
+import { DEMO_WORLD, demoId } from "../ids";
+
+// ---------------------------------------------------------------------------
+// Signals. Ordered by how strongly they should pull a prompt to the front:
+// fresh_failure wins, then waiting_run, slow_run, concurrency_saturation.
+// ---------------------------------------------------------------------------
+
+export const demoFreshFailureSignal: AgentPageSignal = {
+ kind: "fresh_failure",
+ runId: DEMO_WORLD.failedRunId,
+ failedAt: "2026-07-27T10:13:41.000Z",
+};
+
+export const demoWaitingRunSignal: AgentPageSignal = {
+ kind: "waiting_run",
+ runId: DEMO_WORLD.waitingRunId,
+ queue: DEMO_WORLD.queue,
+};
+
+export const demoSlowRunSignal: AgentPageSignal = {
+ kind: "slow_run",
+ runId: DEMO_WORLD.slowRunId,
+ durationMs: 1_421_000,
+ baselineP95Ms: 183_000,
+};
+
+export const demoConcurrencySaturationSignal: AgentPageSignal = {
+ kind: "concurrency_saturation",
+ severity: "crit",
+};
+
+/** In priority order — the order a resolver should read them in. */
+export const demoSignalsByPriority: AgentPageSignal[] = [
+ demoFreshFailureSignal,
+ demoWaitingRunSignal,
+ demoSlowRunSignal,
+ demoConcurrencySaturationSignal,
+];
+
+// ---------------------------------------------------------------------------
+// One context per page kind.
+// ---------------------------------------------------------------------------
+
+/** A failed run — the highest-signal page there is. */
+export const demoFailedRunPageContext: AgentPageContext = {
+ page: {
+ kind: "run",
+ runId: DEMO_WORLD.failedRunId,
+ status: "Failed",
+ taskId: DEMO_WORLD.taskId,
+ queue: DEMO_WORLD.queue,
+ },
+ signals: [demoFreshFailureSignal],
+};
+
+/** A run that is queued rather than executing. */
+export const demoWaitingRunPageContext: AgentPageContext = {
+ page: {
+ kind: "run",
+ runId: DEMO_WORLD.waitingRunId,
+ status: "Queued",
+ taskId: DEMO_WORLD.taskId,
+ queue: DEMO_WORLD.queue,
+ },
+ signals: [demoWaitingRunSignal, demoConcurrencySaturationSignal],
+};
+
+/** A run that is executing far past its baseline. */
+export const demoSlowRunPageContext: AgentPageContext = {
+ page: {
+ kind: "run",
+ runId: DEMO_WORLD.slowRunId,
+ status: "Executing",
+ taskId: DEMO_WORLD.slowTaskId,
+ },
+ signals: [demoSlowRunSignal],
+};
+
+/** The runs list, filtered to failures in the last day. */
+export const demoRunsPageContext: AgentPageContext = {
+ page: { kind: "runs", filters: { statuses: ["COMPLETED_WITH_ERROR"], period: "24h" } },
+ signals: [demoFreshFailureSignal],
+};
+
+export const demoErrorPageContext: AgentPageContext = {
+ page: { kind: "error", fingerprint: DEMO_WORLD.errorFingerprint },
+ signals: [demoFreshFailureSignal],
+};
+
+export const demoQueuePageContext: AgentPageContext = {
+ page: { kind: "queue", name: DEMO_WORLD.queue, health: "crit" },
+ signals: [demoConcurrencySaturationSignal, demoWaitingRunSignal],
+};
+
+export const demoDeploymentPageContext: AgentPageContext = {
+ page: { kind: "deployment", version: DEMO_WORLD.deploymentVersion },
+ signals: [],
+};
+
+/** An unclassified route — the agent still gets the path. */
+export const demoOtherPageContext: AgentPageContext = {
+ page: { kind: "other", path: "/orgs/demo/projects/demo/env/prod/settings" },
+ signals: [],
+};
+
+export const demoPageContexts = {
+ failedRun: demoFailedRunPageContext,
+ waitingRun: demoWaitingRunPageContext,
+ slowRun: demoSlowRunPageContext,
+ runs: demoRunsPageContext,
+ error: demoErrorPageContext,
+ queue: demoQueuePageContext,
+ deployment: demoDeploymentPageContext,
+ other: demoOtherPageContext,
+} as const;
+
+export type DemoPageContextKey = keyof typeof demoPageContexts;
+
+// ---------------------------------------------------------------------------
+// Chips. Never more than SUGGESTED_PROMPT_CAP, `promoted` always first.
+// ---------------------------------------------------------------------------
+
+const prompt = (
+ id: string,
+ label: string,
+ promptText: string,
+ source: SuggestedPrompt["source"]
+): SuggestedPrompt => ({ id: demoId(`prompt-${id}`), label, prompt: promptText, source });
+
+const DEFAULT_PROMPTS: SuggestedPrompt[] = [
+ prompt("what-can-you-do", "What can you help me with?", "What can you help me with?", "default"),
+ prompt("retries", "How do retries work?", "How do retries work in Trigger.dev?", "default"),
+ prompt(
+ "health",
+ "How's my environment?",
+ "Give me a health report for this environment.",
+ "default"
+ ),
+];
+
+/**
+ * The chip set per page. The first entry of a contextual set is `promoted` —
+ * that's the promoted slot: one chip the host decided is worth jumping the
+ * queue, derived from the page's strongest signal.
+ */
+export const demoPromptSets: Record = {
+ failedRun: [
+ prompt(
+ "investigate-failure",
+ "Why did this run fail?",
+ `Investigate why ${DEMO_WORLD.failedRunId} failed.`,
+ "promoted"
+ ),
+ prompt(
+ "same-error",
+ "Is this happening to other runs?",
+ "How many other runs failed with this error in the last hour?",
+ "contextual"
+ ),
+ prompt(
+ "watch-retry",
+ "Tell me when it retries",
+ `Watch ${DEMO_WORLD.failedRunId} and tell me when it finishes.`,
+ "contextual"
+ ),
+ DEFAULT_PROMPTS[1]!,
+ ],
+ waitingRun: [
+ prompt(
+ "why-waiting",
+ "Why hasn't this started?",
+ `Why is ${DEMO_WORLD.waitingRunId} still queued?`,
+ "promoted"
+ ),
+ prompt(
+ "backlog-drain",
+ "When will the backlog clear?",
+ `When will the ${DEMO_WORLD.queue} backlog drain?`,
+ "contextual"
+ ),
+ DEFAULT_PROMPTS[2]!,
+ ],
+ slowRun: [
+ prompt(
+ "why-slow",
+ "Why is this run slow?",
+ `Why is ${DEMO_WORLD.slowRunId} taking so long?`,
+ "promoted"
+ ),
+ prompt(
+ "compare-baseline",
+ "Compare to a normal run",
+ `How does ${DEMO_WORLD.slowRunId} compare to a normal run of this task?`,
+ "contextual"
+ ),
+ DEFAULT_PROMPTS[0]!,
+ ],
+ runs: [
+ prompt(
+ "failure-pattern",
+ "What's failing most?",
+ "Which tasks are failing most in the last 24 hours?",
+ "promoted"
+ ),
+ prompt(
+ "chart-failures",
+ "Chart the failures",
+ "Chart failed runs per hour by task over the last 24 hours.",
+ "contextual"
+ ),
+ DEFAULT_PROMPTS[2]!,
+ ],
+ error: [
+ prompt(
+ "explain-error",
+ "Explain this error",
+ "Explain this error and what usually causes it.",
+ "promoted"
+ ),
+ prompt(
+ "watch-recurrence",
+ "Tell me if it comes back",
+ "Watch this error and tell me if it happens again.",
+ "contextual"
+ ),
+ DEFAULT_PROMPTS[1]!,
+ ],
+ queue: [
+ prompt(
+ "why-saturated",
+ "Why is this queue backed up?",
+ `Why is ${DEMO_WORLD.queue} backed up?`,
+ "promoted"
+ ),
+ prompt(
+ "raise-limit",
+ "Should I raise the limit?",
+ "Should I raise the concurrency limit on this queue?",
+ "contextual"
+ ),
+ DEFAULT_PROMPTS[2]!,
+ ],
+ deployment: [
+ prompt(
+ "deploy-diff",
+ "What changed in this deploy?",
+ "What changed in this deployment?",
+ "contextual"
+ ),
+ ...DEFAULT_PROMPTS.slice(0, 2),
+ ],
+ other: DEFAULT_PROMPTS,
+};
+
+/** Chips the user has dismissed — the row must not offer them again. */
+export const demoDismissedPromptIds: string[] = [demoId("prompt-watch-retry")];
+
+/** What the row shows after the dismissal above, still capped. */
+export const demoPromptsAfterDismissal: SuggestedPrompt[] = demoPromptSets.failedRun
+ .filter((p) => !demoDismissedPromptIds.includes(p.id))
+ .slice(0, SUGGESTED_PROMPT_CAP);
+
+export const demoPrompts = {
+ sets: demoPromptSets,
+ defaults: DEFAULT_PROMPTS,
+ dismissedIds: demoDismissedPromptIds,
+ afterDismissal: demoPromptsAfterDismissal,
+ cap: SUGGESTED_PROMPT_CAP,
+} as const;
diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/reports.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/reports.ts
new file mode 100644
index 00000000000..e285043d435
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/reports.ts
@@ -0,0 +1,249 @@
+/**
+ * Report fixtures — two realistic `ReportViewModel`s (the real type from
+ * `~/presenters/v3/reports/report-view-model`, no demo mirror), one healthy and
+ * one degraded.
+ *
+ * They are hand-written rather than produced by `interpret()` on purpose: the
+ * point of the mockup is to pin what the *card* must render, and a fixture VM
+ * lets the reviewer see a specific degraded story (env-limit saturation with a
+ * drainable backlog) without seeding ClickHouse. Every code used here exists in
+ * the real health message catalog, so the demo card resolves them through the
+ * production prose — no invented wording.
+ */
+import type { ReportViewModel } from "~/presenters/v3/reports/report-view-model";
+import { DEMO_WORLD } from "../ids";
+
+// A believable 60-point minute series, shaped rather than random so the
+// sparklines read as a story (calm, then a ramp).
+const calm = (base: number, jitter: number) =>
+ Array.from({ length: 60 }, (_, i) => base + Math.round(Math.sin(i / 4) * jitter));
+
+const ramp = (from: number, to: number) =>
+ Array.from({ length: 60 }, (_, i) => Math.round(from + ((to - from) * i) / 59));
+
+/** Everything is fine — the "nothing to do" shape of the card. */
+export const demoHealthyReport: ReportViewModel = {
+ title: "health",
+ scope: "prod",
+ period: "last 1h",
+ baselineLabel: "vs your 7d normal",
+ generatedAt: "2026-07-27T10:15:00.000Z",
+ windowMinutes: 60,
+ summary: {
+ severity: "ok",
+ statements: [
+ { findingType: "flow", severity: "ok" },
+ { findingType: "execution", severity: "ok" },
+ { findingType: "liveness", severity: "ok" },
+ ],
+ },
+ findings: [
+ {
+ type: "flow",
+ severity: "ok",
+ reason: "healthy",
+ read: "starting_normally",
+ metricIds: ["start_latency_p95", "pending", "throughput"],
+ },
+ {
+ type: "execution",
+ severity: "ok",
+ reason: "healthy",
+ read: "runs_are_fine",
+ metricIds: ["failures", "dur_p95"],
+ },
+ {
+ type: "liveness",
+ severity: "ok",
+ reason: "fresh",
+ metricIds: ["liveness"],
+ },
+ ],
+ metrics: [
+ {
+ id: "start_latency_p95",
+ value: 6_800,
+ unit: "ms",
+ aggregation: "p95",
+ normal: 7_000,
+ delta: { dir: "down", mult: 1 },
+ series: { points: calm(6_800, 400), kind: "measured" },
+ severity: "ok",
+ },
+ {
+ id: "pending",
+ value: 34,
+ unit: "count",
+ normal: 40,
+ delta: { dir: "down", mult: 1 },
+ series: { points: calm(36, 8), kind: "measured" },
+ severity: "ok",
+ },
+ {
+ id: "throughput",
+ value: 12,
+ unit: "perMin",
+ aggregation: "rate",
+ breakdown: { done: 842, triggered: 830 },
+ severity: "ok",
+ },
+ {
+ id: "failures",
+ value: 0.004,
+ unit: "ratio",
+ aggregation: "ratio",
+ normal: 0.005,
+ delta: { dir: "down", mult: 1 },
+ series: { points: calm(4, 2).map((n) => n / 1000), kind: "measured" },
+ severity: "ok",
+ },
+ {
+ id: "dur_p95",
+ value: 4_100,
+ unit: "ms",
+ aggregation: "p95",
+ normal: 4_000,
+ delta: { dir: "flat", mult: 1 },
+ severity: "ok",
+ },
+ { id: "liveness", value: 21_000, unit: "ms", availability: "measured", severity: "ok" },
+ ],
+ facts: { trustworthy: true, flowSource: "queue", pendingEstimated: false },
+ links: [],
+ footer: [{ code: "nothing_to_do" }],
+};
+
+/**
+ * Degraded: pinned at the env concurrency limit, backlog climbing, execution
+ * still fine — the case where the card has to say "not your code" out loud.
+ */
+export const demoDegradedReport: ReportViewModel = {
+ title: "health",
+ scope: "prod",
+ period: "last 1h",
+ baselineLabel: "vs your 7d normal",
+ generatedAt: "2026-07-27T10:15:00.000Z",
+ windowMinutes: 60,
+ summary: {
+ severity: "crit",
+ statements: [
+ { findingType: "flow", severity: "crit" },
+ { findingType: "execution", severity: "ok" },
+ { findingType: "liveness", severity: "ok" },
+ ],
+ },
+ findings: [
+ {
+ type: "flow",
+ severity: "crit",
+ reason: "env_limit_saturation",
+ read: "saturation_chain",
+ metricIds: ["concurrency", "pending", "start_latency_p95", "throughput"],
+ recommendation: { code: "raise_env_limit", link: "concurrency_docs" },
+ anomalyWindow: { minutes: 38, touchesEnd: true },
+ attribution: { dim: "queue", key: DEMO_WORLD.queue, share: 0.71, of: "pending" },
+ exclusions: [{ code: "not_your_code", evidence: { failures: 0.006 } }],
+ observations: [{ code: "not_workers_platform", evidence: { rate: 820 } }],
+ },
+ {
+ type: "execution",
+ severity: "ok",
+ reason: "healthy",
+ read: "not_a_code_problem",
+ metricIds: ["failures", "dur_p95"],
+ },
+ { type: "liveness", severity: "ok", reason: "fresh", metricIds: ["liveness"] },
+ ],
+ metrics: [
+ {
+ id: "start_latency_p95",
+ value: 43_000,
+ unit: "ms",
+ aggregation: "p95",
+ normal: 7_000,
+ delta: { dir: "up", mult: 6 },
+ series: { points: ramp(7_000, 43_000), kind: "measured" },
+ severity: "crit",
+ },
+ {
+ id: "pending",
+ value: 4_812,
+ unit: "count",
+ normal: 40,
+ delta: { dir: "up", mult: 120 },
+ series: { points: ramp(60, 4_812), kind: "measured" },
+ severity: "crit",
+ },
+ {
+ id: "throughput",
+ value: -180,
+ unit: "perMin",
+ aggregation: "rate",
+ breakdown: { done: 820, triggered: 1_000 },
+ severity: "warn",
+ },
+ {
+ id: "failures",
+ value: 0.006,
+ unit: "ratio",
+ aggregation: "ratio",
+ normal: 0.005,
+ delta: { dir: "up", mult: 1 },
+ series: { points: calm(6, 2).map((n) => n / 1000), kind: "measured" },
+ severity: "ok",
+ },
+ {
+ id: "dur_p95",
+ value: 4_200,
+ unit: "ms",
+ aggregation: "p95",
+ normal: 4_000,
+ delta: { dir: "flat", mult: 1 },
+ severity: "ok",
+ },
+ {
+ id: "concurrency",
+ value: 50,
+ unit: "count",
+ breakdown: { limit: 50 },
+ series: { points: ramp(28, 50), kind: "measured" },
+ annotation: { code: "pinned_minutes", value: 38 },
+ severity: "ok",
+ },
+ {
+ id: "triggered",
+ value: 1_000,
+ unit: "perMin",
+ normal: 840,
+ delta: { dir: "up", mult: 1 },
+ severity: "ok",
+ },
+ { id: "liveness", value: 18_000, unit: "ms", availability: "measured", severity: "ok" },
+ ],
+ facts: {
+ trustworthy: true,
+ flowSource: "queue",
+ pendingEstimated: false,
+ throughput: { donePerMin: 820, triggeredPerMin: 1_000 },
+ },
+ links: [
+ {
+ key: "concurrency_docs",
+ label: "Concurrency & limits",
+ url: "https://trigger.dev/docs/queue-concurrency",
+ },
+ { key: "contact", label: "Contact us", url: "https://trigger.dev/contact" },
+ ],
+ footer: [
+ // Raising the env limit is a plan quota — the action is contacting us; the
+ // docs ride alongside as their own button.
+ { code: "contact_us_raise_limit", link: "contact" },
+ { code: "concurrency_docs", link: "concurrency_docs" },
+ { code: "do_nothing_drains", value: 26.7 },
+ ],
+};
+
+export const demoReports = {
+ healthy: demoHealthyReport,
+ degraded: demoDegradedReport,
+} as const;
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..87d5bfed13a
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts
@@ -0,0 +1,183 @@
+/**
+ * Watch fixtures — specs typed against the contracts package, plus the chip
+ * state the panel shows for each and the narration the agent writes when a
+ * watch wakes, expires, or can't verify its condition.
+ *
+ * The chip is the only always-visible piece of a watch, so it carries its own
+ * state (`active` / `fired` / `expired` / `cancelled`) straight from
+ * `watchStatuses`, and its identity comes from `watchIdentity(spec)` — the same
+ * dedupe key the host uses, so two chips can never disagree with the store
+ * about whether they watch the same thing.
+ */
+import {
+ watchIdentity,
+ type WatchSpec,
+ type WatchStatus,
+} from "@internal/dashboard-agent-contracts";
+import { DEMO_WORLD, demoId } from "../ids";
+
+export type DemoWatch = {
+ /** Demo-namespaced watch id. */
+ id: string;
+ spec: WatchSpec;
+ status: WatchStatus;
+ /** Dedupe identity, derived — never hand-written. */
+ identity: string;
+ /** Short chip label, e.g. "backlog-drain". */
+ chipLabel: string;
+ /** When it was created / when it will expire, for the chip tooltip. */
+ createdAt: string;
+ expiresAt: string;
+ /** Whether the chip offers a cancel affordance. Only an active watch does. */
+ 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",
+});
+
+/** Active: waiting for a specific run to finish. Run-state cadence — 1 minute is legal. */
+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"
+);
+
+/** Active: aggregate condition, so the cadence floor is 5 minutes. */
+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"
+);
+
+/** Fired: the condition happened and the user has been told. */
+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"
+);
+
+/** Expired without ever being satisfied. */
+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"
+);
+
+/** Cancelled by the user from the chip. */
+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"
+);
+
+/** The chip row as it looks with watches in every state at once. */
+export const demoWatchRow: DemoWatch[] = [
+ demoRunFinishedWatch,
+ demoBacklogDrainWatch,
+ demoErrorRecurrenceWatch,
+ demoHealthRecoveryWatch,
+ demoCancelledWatch,
+];
+
+/** Just the live ones — the normal case the header shows. */
+export const demoActiveWatchRow: DemoWatch[] = [demoRunFinishedWatch, demoBacklogDrainWatch];
+
+// ---------------------------------------------------------------------------
+// Narration. A watch speaks exactly once per outcome, in the chat, unprompted —
+// so the wording carries the whole burden of explaining why a message appeared.
+// ---------------------------------------------------------------------------
+
+export const demoWatchNarration = {
+ /** The watch fired: say what happened, what it means, and stop watching. */
+ 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.`,
+
+ /** Expired having verified the condition never happened. */
+ 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.`,
+
+ /**
+ * Expired unable to verify. Different failure mode from the above and it must
+ * not be dressed up as an answer: the watch could not check, so it says so.
+ */
+ 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 from the chip. Short, no narration theatre. */
+ 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
new file mode 100644
index 00000000000..1605d1440d4
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/ids.ts
@@ -0,0 +1,108 @@
+/**
+ * Demo-mode identity rules. Everything the demo layer invents lives in its own
+ * namespace so a demo artefact can never be mistaken for — or written next to —
+ * a real one.
+ *
+ * Two conventions, on purpose:
+ *
+ * 1. **Our own namespace** (chats, investigations, watches) uses the literal
+ * `demo:` prefix, so `isDemoChatId` answers "this id is a fixture, never
+ * talk to the server about it".
+ * 2. **Resource ids** (runs, queues, errors, deployments, source shas) keep
+ * their real-world shape but always carry the `demo` marker inside the id
+ * (`run_demo0f2c91`, `demo-email-sends`). They have to stay shaped like real
+ * ids because they travel through `trigger://` URIs, whose grammar is frozen
+ * and whose segments are percent-encoded — a `demo:` prefix would render as
+ * `demo%3A…` in every citation and make the mockup unreadable.
+ *
+ * The invariant both conventions share, and the one the test enforces: every id
+ * the demo layer produces contains the string `demo`.
+ */
+import { formatTriggerUri, type TriggerUri } from "@internal/dashboard-agent-contracts";
+
+/** Prefix for every id in the demo layer's own namespace. */
+export const DEMO_ID_PREFIX = "demo:";
+
+/** The marker every demo id — ours or resource-shaped — must contain. */
+export const DEMO_MARKER = "demo";
+
+/** `demo:` + the rest. Use for chats, investigations and watches. */
+export function demoId(rest: string): string {
+ return `${DEMO_ID_PREFIX}${rest}`;
+}
+
+/**
+ * True for a chat id the demo registry owns — cheap and total, no lookups and
+ * no async, so any renderer can ask before it touches the server.
+ */
+export function isDemoChatId(id: string | null | undefined): boolean {
+ return typeof id === "string" && id.startsWith(DEMO_ID_PREFIX);
+}
+
+/** The fake project/environment every demo `trigger://` URI is scoped to. */
+export const DEMO_PROJECT_REF = "proj_demo00000000000000";
+export const DEMO_ENVIRONMENT_ID = "env_demo00000000000000";
+
+const scope = { projectRef: DEMO_PROJECT_REF, environmentId: DEMO_ENVIRONMENT_ID };
+
+// Builders go through `formatTriggerUri` rather than string templates so every
+// fixture URI is grammar-valid by construction — the contracts package, not the
+// fixture author, decides what a legal URI looks like.
+
+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 });
+}
+
+// ---------------------------------------------------------------------------
+// The demo world: one small, consistent cast of resources every fixture reuses,
+// so the mockup reads as one environment rather than a pile of unrelated cards.
+// ---------------------------------------------------------------------------
+
+export const DEMO_WORLD = {
+ /** The failing run at the centre of the Investigate flow. */
+ failedRunId: "run_demo0f2c91",
+ failedSpanId: "span_demoa41b",
+ /** A run that is sitting in the queue rather than executing. */
+ waitingRunId: "run_demo7b41ad",
+ /** A run running well past its task's usual duration. */
+ slowRunId: "run_democ0113e",
+ /** A historical run that failed the same way. */
+ priorRunId: "run_demo4419bb",
+ taskId: "send-order-receipt",
+ slowTaskId: "generate-monthly-report",
+ queue: "demo-email-sends",
+ backlogQueue: "demo-backlog-drain",
+ errorFingerprint: "error_demo5a1c73",
+ deploymentVersion: "20260726.4-demo",
+ sourceSha: "demo1a2b3c4d5e6f70",
+ sourcePath: "src/trigger/sendOrderReceipt.ts",
+ reportKey: "health",
+} as const;
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..31a12bf5563
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/index.ts
@@ -0,0 +1,27 @@
+/**
+ * Dashboard agent design fixtures — the public surface.
+ *
+ * A set of canned conversations and card payloads rendered by the real panel
+ * components with no transport, no LLM and no writes, so every v1 state can be
+ * reviewed as UI. The state gallery (`/storybook/agent-ui`) is the consumer.
+ *
+ * The panel itself never renders any of this: its example conversations are real
+ * stored chats, seeded by `pnpm --filter webapp run db:seed:agent-examples`.
+ *
+ * This module must stay free of server imports — the isolation test in
+ * `demo.test.ts` asserts that.
+ */
+export { demoChatById, demoChats, type DemoChat, type DemoFlow, type DemoItem } from "./demo-chats";
+export { DEMO_ID_PREFIX, DEMO_MARKER, DEMO_WORLD, demoId } from "./ids";
+
+// The fixtures themselves, so a storybook page (or a later test) can render one
+// card at a time without going through a conversation.
+export * as demoFixtures from "./fixtures";
+
+// The demo-only cards, for the same reason.
+export { DemoChartCard } from "./components/DemoChartCard";
+export { DemoIntentBubble, DemoNote } from "./components/DemoIntentBubble";
+export { DemoInvestigationCard } from "./components/DemoInvestigationCard";
+export { DemoReportCard } from "./components/DemoReportCard";
+export { DemoSuggestedPromptsRow } from "./components/DemoSuggestedPromptsRow";
+export { DemoWatchChips } from "./components/DemoWatchChips";
diff --git a/apps/webapp/app/components/dashboard-agent/investigate-prompts.test.ts b/apps/webapp/app/components/dashboard-agent/investigate-prompts.test.ts
new file mode 100644
index 00000000000..3045b28868b
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/investigate-prompts.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest";
+import {
+ errorGroupPrompt,
+ failedRunPrompt,
+ isFailedRunStatus,
+ queueBacklogPrompt,
+ waitingRunPrompt,
+} from "./investigate-prompts";
+
+describe("isFailedRunStatus", () => {
+ it("is true for failure statuses", () => {
+ for (const status of [
+ "COMPLETED_WITH_ERRORS",
+ "CRASHED",
+ "SYSTEM_FAILURE",
+ "TIMED_OUT",
+ "EXPIRED",
+ ]) {
+ expect(isFailedRunStatus(status)).toBe(true);
+ }
+ });
+
+ it("is false for everything else", () => {
+ for (const status of ["PENDING", "EXECUTING", "COMPLETED_SUCCESSFULLY", "CANCELED", "PAUSED"]) {
+ expect(isFailedRunStatus(status)).toBe(false);
+ }
+ });
+});
+
+describe("investigate prompts", () => {
+ it("names the run that failed", () => {
+ expect(failedRunPrompt("run_abc")).toBe("Investigate run run_abc — why did it fail?");
+ });
+
+ it("names the queue a waiting run is stuck in, when known", () => {
+ expect(waitingRunPrompt("run_abc", "emails")).toBe(
+ "Why is run run_abc waiting to start in the emails queue?"
+ );
+ expect(waitingRunPrompt("run_abc")).toBe("Why is run run_abc waiting to start?");
+ });
+
+ it("names the error and its task, when known", () => {
+ expect(errorGroupPrompt("error_abc", "send-email")).toBe(
+ "Investigate error error_abc in send-email — what's causing it and is it still happening?"
+ );
+ expect(errorGroupPrompt("error_abc")).toBe(
+ "Investigate error error_abc — what's causing it and is it still happening?"
+ );
+ });
+
+ it("names the backed-up queue", () => {
+ expect(queueBacklogPrompt("emails")).toBe(
+ "Investigate the emails queue — why is it backed up?"
+ );
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/investigate-prompts.ts b/apps/webapp/app/components/dashboard-agent/investigate-prompts.ts
new file mode 100644
index 00000000000..9a4a52a11f5
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/investigate-prompts.ts
@@ -0,0 +1,50 @@
+/**
+ * The prompt text the dashboard's "Investigate" buttons send.
+ *
+ * Kept out of the routes (and out of the button component) so the wording is in
+ * one place and testable — these strings are product copy, and they carry real
+ * identifiers so the agent starts from the thing the user is looking at.
+ * Phrasing follows the suggested-prompt registry (`suggested-prompts/registry.ts`).
+ */
+
+/**
+ * Run statuses that mean "this failed", so an Investigate button belongs on the
+ * run. Same set as the fresh-failure signal uses (`suggested-prompts/page-mappers.ts`).
+ */
+const FAILED_RUN_STATUSES = new Set([
+ "COMPLETED_WITH_ERRORS",
+ "CRASHED",
+ "SYSTEM_FAILURE",
+ "TIMED_OUT",
+ "EXPIRED",
+]);
+
+/** Whether a run's status is a failure the agent can investigate. */
+export function isFailedRunStatus(status: string): boolean {
+ return FAILED_RUN_STATUSES.has(status);
+}
+
+/** "Investigate run …" — a failed run. */
+export function failedRunPrompt(runFriendlyId: string): string {
+ return `Investigate run ${runFriendlyId} — why did it fail?`;
+}
+
+/** "Why is run … waiting …" — a run that hasn't started yet. */
+export function waitingRunPrompt(runFriendlyId: string, queueName?: string): string {
+ return queueName
+ ? `Why is run ${runFriendlyId} waiting to start in the ${queueName} queue?`
+ : `Why is run ${runFriendlyId} waiting to start?`;
+}
+
+/** "Investigate error …" — an error group. */
+export function errorGroupPrompt(errorFriendlyId: string, taskIdentifier?: string): string {
+ const subject = taskIdentifier
+ ? `error ${errorFriendlyId} in ${taskIdentifier}`
+ : `error ${errorFriendlyId}`;
+ return `Investigate ${subject} — what's causing it and is it still happening?`;
+}
+
+/** "Investigate the … queue" — a queue in warn/crit health. */
+export function queueBacklogPrompt(queueName: string): string {
+ return `Investigate the ${queueName} queue — why is it backed up?`;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/list-row.tsx b/apps/webapp/app/components/dashboard-agent/list-row.tsx
new file mode 100644
index 00000000000..4400396bfbb
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/list-row.tsx
@@ -0,0 +1,114 @@
+/**
+ * The panel's shared list view.
+ *
+ * Both lists in the panel — suggested prompts and chat history — are the same
+ * thing: a selectable row with an optional hover action. Keeping the row and
+ * list styling here is what makes the panel read as one system instead of two
+ * lists that drifted apart.
+ */
+import type { ComponentType, ReactNode } from "react";
+import { cn } from "~/utils/cn";
+
+/** The list wrapper. Rows are `
`, so the list itself is an ``. */
+export function AgentList({ children, className }: { children: ReactNode; className?: string }) {
+ return {children};
+}
+
+/**
+ * - `default` — a plain row.
+ * - `promoted` — the product-chosen prompt.
+ * - `selected` — the row the panel is currently showing (the open chat).
+ */
+export type AgentListRowVariant = "default" | "promoted" | "selected";
+
+const ROW_VARIANTS: Record = {
+ default:
+ "border-grid-bright bg-background-bright/40 text-text-dimmed hover:border-border-bright hover:text-text-bright",
+ promoted: "border-indigo-500/40 bg-indigo-500/5 text-text-bright hover:border-indigo-500/60",
+ selected: "border-border-bright bg-background-bright text-text-bright",
+};
+
+export function AgentListRow({
+ label,
+ meta,
+ status,
+ variant = "default",
+ unread = false,
+ onSelect,
+ action,
+}: {
+ /** The row's main text. Truncated to one line so every row is the same height. */
+ label: ReactNode;
+ /** Small right-aligned detail, e.g. when a chat was last active. */
+ meta?: ReactNode;
+ /**
+ * Trailing icon for something ongoing in this row, e.g. a chat the agent is
+ * still working in. Sits before {@link meta}, inside the row's own button.
+ */
+ status?: ReactNode;
+ variant?: AgentListRowVariant;
+ /**
+ * Something happened here the user hasn't seen. Brightens the label and adds
+ * the same indigo dot the launcher uses, so the two read as one signal.
+ */
+ unread?: boolean;
+ onSelect: () => void;
+ /** Hover-revealed control, e.g. dismiss or delete. Use {@link AgentListRowAction}. */
+ action?: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+/** The hover-revealed icon button on the right of a row. */
+export function AgentListRowAction({
+ icon: Icon,
+ label,
+ onClick,
+ danger = false,
+}: {
+ icon: ComponentType<{ className?: string }>;
+ /** Accessible name — the control is icon-only. */
+ label: string;
+ onClick: () => void;
+ /** Destructive actions go red on hover. */
+ danger?: boolean;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/message-order.test.ts b/apps/webapp/app/components/dashboard-agent/message-order.test.ts
new file mode 100644
index 00000000000..26a89318ffe
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/message-order.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { createTranscriptOrder, orderTranscript } from "./message-order";
+
+const message = (id: string, parts = 1) => ({
+ id,
+ parts: Array.from({ length: parts }, () => ({ type: "text" })),
+});
+
+describe("orderTranscript", () => {
+ it("keeps the stored order and appends live messages after it", () => {
+ const base = [message("a"), message("b")];
+ const order = createTranscriptOrder(base);
+
+ const result = orderTranscript([...base, message("live-1"), message("live-2")], order);
+
+ expect(result.map((m) => m.id)).toEqual(["a", "b", "live-1", "live-2"]);
+ });
+
+ it("puts a replayed stored turn back in its own slot, not after a live message", () => {
+ const base = [message("a"), message("b")];
+ const order = createTranscriptOrder(base);
+
+ // The user's message is appended locally, then the stream replays "b".
+ const result = orderTranscript([...base, message("sent"), message("b", 2)], order);
+
+ expect(result.map((m) => m.id)).toEqual(["a", "b", "sent"]);
+ // The replayed copy is the one rendered.
+ expect(result[1]!.parts).toHaveLength(2);
+ });
+
+ it("keeps live arrival order stable as later renders add messages", () => {
+ const order = createTranscriptOrder([message("a")]);
+
+ orderTranscript([message("a"), message("x")], order);
+ const result = orderTranscript([message("a"), message("y"), message("x")], order);
+
+ expect(result.map((m) => m.id)).toEqual(["a", "x", "y"]);
+ });
+
+ it("prefers the copy with parts while a duplicate is still empty", () => {
+ const order = createTranscriptOrder([]);
+
+ const result = orderTranscript([message("a", 3), message("a", 0)], order);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]!.parts).toHaveLength(3);
+ });
+
+ it("is a no-op for a transcript with nothing to reorder", () => {
+ const messages = [message("a"), message("b"), message("c")];
+ const order = createTranscriptOrder(messages);
+
+ expect(orderTranscript(messages, order).map((m) => m.id)).toEqual(["a", "b", "c"]);
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/message-order.ts b/apps/webapp/app/components/dashboard-agent/message-order.ts
new file mode 100644
index 00000000000..ecc9c21f95d
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/message-order.ts
@@ -0,0 +1,74 @@
+/**
+ * Stable transcript order.
+ *
+ * The stored copy of a chat is the base order; anything that arrives live goes
+ * strictly after it, in the order it first appeared. Two things make that
+ * necessary:
+ *
+ * - The panel remounts the chat on every page navigation, seeded with the
+ * store's transcript. That copy can lag the turn that just finished (the
+ * loader reads `messages` and the stream cursor separately), so the stream can
+ * replay a turn the base already has — or one it doesn't.
+ * - A message sent right after the remount (a card's Investigate button) is
+ * appended locally, so a replay landing afterwards used to render *after* it,
+ * putting the user's own message in the middle of the transcript.
+ *
+ * Keying the order by message id fixes both: a replayed turn goes back into its
+ * own slot, and live messages stay in arrival order at the end.
+ */
+
+export type TranscriptOrder = {
+ /** Message id -> its index in the stored transcript. */
+ base: Map;
+ /** Message id -> the order it first arrived live. */
+ live: Map;
+};
+
+/** The order to rank against: the stored transcript, as loaded. */
+export function createTranscriptOrder(base: ReadonlyArray<{ id: string }>): TranscriptOrder {
+ return {
+ base: new Map(base.map((message, index) => [message.id, index])),
+ live: new Map(),
+ };
+}
+
+type Orderable = { id: string; parts?: ReadonlyArray };
+
+/**
+ * The messages in stable order, one copy per id.
+ *
+ * Registers ids it hasn't seen before in `order.live`, so the order object must
+ * be long-lived (a ref) — it is the memory of what arrived when.
+ */
+export function orderTranscript(
+ messages: ReadonlyArray,
+ order: TranscriptOrder
+): T[] {
+ const chosen = new Map();
+
+ for (const message of messages) {
+ if (!order.base.has(message.id) && !order.live.has(message.id)) {
+ order.live.set(message.id, order.live.size);
+ }
+ const existing = chosen.get(message.id);
+ // The later copy wins — a streamed copy is the fresher one — unless it has
+ // no parts yet, when the copy we already have is the one that says something.
+ if (existing && partCount(message) === 0 && partCount(existing) > 0) continue;
+ chosen.set(message.id, message);
+ }
+
+ return [...chosen.values()]
+ .map((message, arrival) => ({ message, arrival, rank: rankOf(message.id, order) }))
+ .sort((a, b) => a.rank - b.rank || a.arrival - b.arrival)
+ .map((entry) => entry.message);
+}
+
+function partCount(message: Orderable): number {
+ return message.parts?.length ?? 0;
+}
+
+function rankOf(id: string, order: TranscriptOrder): number {
+ const base = order.base.get(id);
+ if (base !== undefined) return base;
+ return order.base.size + (order.live.get(id) ?? 0);
+}
diff --git a/apps/webapp/app/components/dashboard-agent/navigate-target.test.ts b/apps/webapp/app/components/dashboard-agent/navigate-target.test.ts
new file mode 100644
index 00000000000..3253fa66c1e
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/navigate-target.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from "vitest";
+import { appendRunFilters, pendingNavigateIntents, sameOriginPath } from "./navigate-target";
+
+const RUNS_PATH = "/orgs/acme/projects/api/env/prod/runs";
+
+describe("appendRunFilters", () => {
+ it("returns the path untouched with no filters", () => {
+ expect(appendRunFilters(RUNS_PATH)).toBe(RUNS_PATH);
+ });
+
+ it("writes arrays as repeated params and keeps existing ones", () => {
+ const result = appendRunFilters(`${RUNS_PATH}?query=payments`, {
+ statuses: ["FAILED", "CRASHED"],
+ tasks: "send-email",
+ period: "1d",
+ });
+
+ expect(result).toBe(
+ `${RUNS_PATH}?query=payments&statuses=FAILED&statuses=CRASHED&tasks=send-email&period=1d`
+ );
+ });
+
+ it("converts absolute bounds to epoch milliseconds", () => {
+ const result = appendRunFilters(RUNS_PATH, { from: "2026-01-01T00:00:00.000Z" });
+
+ expect(result).toBe(`${RUNS_PATH}?from=${Date.parse("2026-01-01T00:00:00.000Z")}`);
+ });
+
+ it("drops empty values and false booleans", () => {
+ expect(appendRunFilters(RUNS_PATH, { search: "", rootOnly: false, tags: [] })).toBe(RUNS_PATH);
+ expect(appendRunFilters(RUNS_PATH, { rootOnly: true })).toBe(`${RUNS_PATH}?rootOnly=true`);
+ });
+});
+
+describe("sameOriginPath", () => {
+ const origin = "http://localhost:3030";
+
+ it("turns an absolute dashboard URL into a path", () => {
+ expect(sameOriginPath(`${origin}${RUNS_PATH}?statuses=FAILED#top`, origin)).toBe(
+ `${RUNS_PATH}?statuses=FAILED#top`
+ );
+ });
+
+ it("returns null for another origin", () => {
+ expect(sameOriginPath("https://trigger.dev/docs/errors", origin)).toBeNull();
+ });
+
+ it("returns null for a URL it can't parse", () => {
+ expect(sameOriginPath("not a url", "")).toBeNull();
+ });
+});
+
+describe("pendingNavigateIntents", () => {
+ const uri = "trigger://proj_abc/env_123/run/run_abc";
+ const toolPart = (toolCallId: string, state = "output-available") => ({
+ type: "tool-navigate_to",
+ state,
+ toolCallId,
+ output: { intent: { kind: "navigate", target: uri } },
+ });
+
+ it("returns the intent from a completed navigate_to call, once", () => {
+ const seen = new Set();
+ const messages = [{ id: "m1", parts: [toolPart("call-1")] }];
+
+ expect(pendingNavigateIntents(messages, seen)).toEqual([{ kind: "navigate", target: uri }]);
+ expect(pendingNavigateIntents(messages, seen)).toEqual([]);
+ });
+
+ it("ignores a call that hasn't produced output yet", () => {
+ expect(
+ pendingNavigateIntents(
+ [{ id: "m1", parts: [toolPart("call-1", "input-available")] }],
+ new Set()
+ )
+ ).toEqual([]);
+ });
+
+ it("ignores output that isn't a navigate intent", () => {
+ const messages = [
+ { id: "m1", parts: [{ ...toolPart("call-1"), output: { error: "nowhere to go" } }] },
+ { id: "m2", parts: [{ type: "text", text: "hello" }] },
+ ];
+
+ expect(pendingNavigateIntents(messages, new Set())).toEqual([]);
+ });
+
+ it("skips calls seeded as already seen (loaded history)", () => {
+ const history = [{ id: "m1", parts: [toolPart("call-1")] }];
+ const seen = new Set();
+ pendingNavigateIntents(history, seen);
+
+ expect(
+ pendingNavigateIntents([...history, { id: "m2", parts: [toolPart("call-2")] }], seen)
+ ).toEqual([{ kind: "navigate", target: uri }]);
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/navigate-target.ts b/apps/webapp/app/components/dashboard-agent/navigate-target.ts
new file mode 100644
index 00000000000..57a83ee9975
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/navigate-target.ts
@@ -0,0 +1,101 @@
+/**
+ * Where a navigate goes, as a dashboard path.
+ *
+ * A `trigger://` target is resolved server-side (`resolveTriggerUri.server.ts`,
+ * reached through the panel's `resolve` action) — this module handles the two
+ * client-side halves: the runs-list filters a navigate intent carries, and links
+ * that arrived as absolute URLs into our own origin.
+ */
+import {
+ agentIntentSchema,
+ type AgentIntent,
+ type RunFilters,
+} from "@internal/dashboard-agent-contracts";
+
+// The filter keys are already the runs page's own URL params (see
+// `TaskRunListSearchFilters`), with one exception: the page reads absolute
+// window bounds as epoch milliseconds while an intent carries ISO strings.
+const EPOCH_MS_KEYS = new Set(["from", "to"]);
+
+/** A resolved path with the intent's filters applied as search params. */
+export function appendRunFilters(path: string, filters?: RunFilters): string {
+ if (!filters) return path;
+ // A base is needed to parse a relative path; only pathname + search is used.
+ const url = new URL(path, "https://dashboard.invalid");
+
+ for (const [key, value] of Object.entries(filters)) {
+ if (value === undefined || value === null || value === "") continue;
+ if (EPOCH_MS_KEYS.has(key)) {
+ const epochMs = Date.parse(String(value));
+ if (!Number.isNaN(epochMs)) url.searchParams.set(key, String(epochMs));
+ continue;
+ }
+ if (Array.isArray(value)) {
+ for (const entry of value) if (entry) url.searchParams.append(key, entry);
+ continue;
+ }
+ if (typeof value === "boolean") {
+ if (value) url.searchParams.set(key, "true");
+ continue;
+ }
+ url.searchParams.set(key, String(value));
+ }
+
+ return `${url.pathname}${url.search}`;
+}
+
+/**
+ * The path to navigate to for a link into our own origin, or null when the link
+ * leaves the dashboard (those keep their new tab).
+ *
+ * The agent cites dashboard resources as absolute URLs, and an absolute URL is
+ * rendered as an external link — a new tab for a page that belongs in this one.
+ */
+export function sameOriginPath(href: string, origin: string): string | null {
+ let url: URL;
+ try {
+ url = new URL(href, origin);
+ } catch {
+ return null;
+ }
+ if (url.origin !== origin) return null;
+ return `${url.pathname}${url.search}${url.hash}`;
+}
+
+type ToolPart = { type?: string; state?: string; toolCallId?: string; output?: unknown };
+type ToolMessage = { id: string; parts?: ReadonlyArray };
+
+/**
+ * The navigate intents from completed `navigate_to` tool calls the host hasn't
+ * honoured yet.
+ *
+ * The tool returns an intent rather than performing the navigation, so the panel
+ * is what actually moves the user — otherwise the agent says "you're now on the
+ * page" and nothing happened. `seen` is mutated with the calls handled, and is
+ * seeded with the transcript loaded at mount so opening an old chat never
+ * navigates on history.
+ */
+export function pendingNavigateIntents(
+ messages: ReadonlyArray,
+ seen: Set
+): AgentIntent[] {
+ const intents: AgentIntent[] = [];
+
+ for (const message of messages) {
+ const parts = message.parts ?? [];
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i] as ToolPart;
+ if (part?.type !== "tool-navigate_to" || part.state !== "output-available") continue;
+
+ const key = part.toolCallId ?? `${message.id}:${i}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ const output = part.output as { intent?: unknown } | undefined;
+ const parsed = agentIntentSchema.safeParse(output?.intent);
+ if (parsed.success && parsed.data.kind === "navigate") intents.push(parsed.data);
+ }
+ }
+
+ return intents;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/page-context-types.ts b/apps/webapp/app/components/dashboard-agent/page-context-types.ts
new file mode 100644
index 00000000000..46905fdf3fd
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/page-context-types.ts
@@ -0,0 +1,12 @@
+// The page-context contract the dashboard agent reads: what page the user is on
+// and what's notable about it. Pages emit facts their loaders already computed,
+// so producing this costs no extra queries.
+//
+// The schemas + types live in `@internal/dashboard-agent-contracts`; this module
+// is the webapp's import point for them, so UI code doesn't reach into the
+// contracts package directly.
+export type {
+ AgentPage,
+ AgentPageContext,
+ AgentPageSignal,
+} from "@internal/dashboard-agent-contracts";
diff --git a/apps/webapp/app/components/dashboard-agent/page-label.test.ts b/apps/webapp/app/components/dashboard-agent/page-label.test.ts
new file mode 100644
index 00000000000..0d1a430df06
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/page-label.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import { agentPageLabel, pageLabelFromPath } from "./page-label";
+
+const envRoot = "/orgs/acme-1234/projects/hello-world-ab12/env/dev";
+
+describe("pageLabelFromPath", () => {
+ it("labels the env root as Overview", () => {
+ expect(pageLabelFromPath(envRoot)).toBe("Overview");
+ expect(pageLabelFromPath(`${envRoot}/`)).toBe("Overview");
+ });
+
+ it("labels known env sections", () => {
+ expect(pageLabelFromPath(`${envRoot}/runs`)).toBe("Runs");
+ expect(pageLabelFromPath(`${envRoot}/queues`)).toBe("Queues");
+ expect(pageLabelFromPath(`${envRoot}/deployments`)).toBe("Deployments");
+ expect(pageLabelFromPath(`${envRoot}/environment-variables`)).toBe("Environment variables");
+ expect(pageLabelFromPath(`${envRoot}/apikeys`)).toBe("API keys");
+ });
+
+ it("labels a detail path by its section", () => {
+ expect(pageLabelFromPath(`${envRoot}/runs/run_abc123`)).toBe("Runs");
+ expect(pageLabelFromPath(`${envRoot}/errors/deadbeef`)).toBe("Errors");
+ });
+
+ it("prettifies unknown sections instead of showing a raw slug", () => {
+ expect(pageLabelFromPath(`${envRoot}/some-new-thing`)).toBe("Some new thing");
+ });
+
+ it("falls back to the last segment outside an env path", () => {
+ expect(pageLabelFromPath("/orgs/acme-1234/settings/members")).toBe("Members");
+ expect(pageLabelFromPath("/account/security")).toBe("Security");
+ });
+
+ it("never returns an empty label", () => {
+ expect(pageLabelFromPath("/")).toBe("Dashboard");
+ expect(pageLabelFromPath("")).toBe("Dashboard");
+ });
+});
+
+describe("agentPageLabel", () => {
+ it("prefers the structured page kind", () => {
+ expect(agentPageLabel({ page: { kind: "runs" }, signals: [] }, `${envRoot}/anything`)).toBe(
+ "Runs"
+ );
+ expect(
+ agentPageLabel(
+ { page: { kind: "run", runId: "run_abc", status: "FAILED", taskId: "t" }, signals: [] },
+ `${envRoot}/runs/run_abc`
+ )
+ ).toBe("Run detail");
+ expect(agentPageLabel({ page: { kind: "queue", name: "default" }, signals: [] }, envRoot)).toBe(
+ "Queue detail"
+ );
+ expect(
+ agentPageLabel({ page: { kind: "deployment", version: "20240101.1" }, signals: [] }, envRoot)
+ ).toBe("Deployment detail");
+ expect(
+ agentPageLabel({ page: { kind: "error", fingerprint: "abc" }, signals: [] }, envRoot)
+ ).toBe("Error detail");
+ });
+
+ it("falls back to the path an unclassified page carries", () => {
+ expect(
+ agentPageLabel({ page: { kind: "other", path: `${envRoot}/queues` }, signals: [] }, "/")
+ ).toBe("Queues");
+ });
+
+ it("falls back to the location with no page context at all", () => {
+ expect(agentPageLabel(undefined, `${envRoot}/schedules`)).toBe("Schedules");
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/page-label.ts b/apps/webapp/app/components/dashboard-agent/page-label.ts
new file mode 100644
index 00000000000..d8fa723fd9d
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/page-label.ts
@@ -0,0 +1,102 @@
+// One human label for "where the user is", used by the context banner.
+//
+// Two sources, on purpose. The structured page context
+// (`useAgentPageContext()`) is the good one: a route that describes itself
+// yields a `page.kind` we can label exactly. Everything else falls back to the
+// URL, which the panel already has. The full pathname keeps going to the agent
+// untouched in `clientData.currentPage` — this module only produces display
+// text.
+
+import type { AgentPage, AgentPageContext } from "./page-context-types";
+
+/** Shown when we can't work out anything better. */
+const FALLBACK_LABEL = "Dashboard";
+
+// `other` is the "we didn't classify this route" kind, so it carries no label
+// of its own and resolves from the path instead.
+const KIND_LABELS: Record, string> = {
+ runs: "Runs",
+ run: "Run detail",
+ error: "Error detail",
+ queue: "Queue detail",
+ deployment: "Deployment detail",
+};
+
+// The dashboard's env-level sections, keyed by their first path segment. Only
+// the ones whose label isn't just the prettified segment need an entry, but
+// listing them all keeps the wording deliberate rather than incidental.
+const SECTION_LABELS: Record = {
+ agents: "Agents",
+ alerts: "Alerts",
+ apikeys: "API keys",
+ batches: "Batches",
+ "bulk-actions": "Bulk actions",
+ branches: "Branches",
+ concurrency: "Concurrency",
+ dashboards: "Dashboards",
+ deployments: "Deployments",
+ "dev-branches": "Branches",
+ "environment-variables": "Environment variables",
+ errors: "Errors",
+ limits: "Limits",
+ logs: "Logs",
+ metrics: "Metrics",
+ models: "Models",
+ playground: "Playground",
+ prompts: "Prompts",
+ query: "Query",
+ queues: "Queues",
+ regions: "Regions",
+ runs: "Runs",
+ schedules: "Schedules",
+ sessions: "Sessions",
+ settings: "Settings",
+ tasks: "Tasks",
+ test: "Test",
+ versions: "Versions",
+ waitpoints: "Waitpoints",
+};
+
+function prettifySegment(segment: string): string {
+ const words = segment.replace(/[-_]+/g, " ").trim();
+ if (!words) return FALLBACK_LABEL;
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+/**
+ * Label a dashboard path. Env-scoped paths (`/orgs/x/projects/y/env/dev/runs`)
+ * label off the section that follows `env/{slug}`; the env root is "Overview".
+ * Anything else (org settings, account pages) falls back to its last segment.
+ */
+export function pageLabelFromPath(pathname: string): string {
+ const segments = pathname.split("/").filter(Boolean);
+ if (segments.length === 0) return FALLBACK_LABEL;
+
+ const envIndex = segments.lastIndexOf("env");
+ if (envIndex !== -1) {
+ // `env` is followed by the env slug, then the section (if any).
+ const section = segments[envIndex + 2];
+ if (!section) return "Overview";
+ return SECTION_LABELS[section] ?? prettifySegment(section);
+ }
+
+ const last = segments[segments.length - 1];
+ return last ? (SECTION_LABELS[last] ?? prettifySegment(last)) : FALLBACK_LABEL;
+}
+
+/**
+ * The banner's label for the current page: the structured page kind when the
+ * route classified itself, else the path.
+ */
+export function agentPageLabel(
+ pageContext: AgentPageContext | undefined,
+ pathname: string
+): string {
+ const page = pageContext?.page;
+ if (page && page.kind !== "other") {
+ return KIND_LABELS[page.kind] ?? pageLabelFromPath(pathname);
+ }
+ // An `other` page carries the raw path it couldn't classify; prefer it over
+ // the location when present, since it's what the agent was told.
+ return pageLabelFromPath(page?.kind === "other" && page.path ? page.path : pathname);
+}
diff --git a/apps/webapp/app/components/dashboard-agent/progress-line.test.ts b/apps/webapp/app/components/dashboard-agent/progress-line.test.ts
new file mode 100644
index 00000000000..1d2693ad50f
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/progress-line.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import { hasToolProgressLine } from "./progress-line";
+
+describe("hasToolProgressLine", () => {
+ it("is true while the last turn has a tool call in flight", () => {
+ expect(
+ hasToolProgressLine([
+ { role: "user", parts: [{ type: "text", text: "how are the queues?" }] },
+ {
+ role: "assistant",
+ parts: [
+ { type: "text", text: "Let me look." },
+ { type: "tool-render_view", state: "input-available" },
+ ],
+ },
+ ])
+ ).toBe(true);
+ });
+
+ it("is false once the tool call has output", () => {
+ expect(
+ hasToolProgressLine([
+ { role: "assistant", parts: [{ type: "tool-render_view", state: "output-available" }] },
+ ])
+ ).toBe(false);
+ });
+
+ it("is false for a text-only turn, an empty transcript, and a user last message", () => {
+ expect(hasToolProgressLine([])).toBe(false);
+ expect(
+ hasToolProgressLine([{ role: "assistant", parts: [{ type: "text", text: "hi" }] }])
+ ).toBe(false);
+ expect(hasToolProgressLine([{ role: "user", parts: [{ type: "text", text: "hi" }] }])).toBe(
+ false
+ );
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/progress-line.ts b/apps/webapp/app/components/dashboard-agent/progress-line.ts
new file mode 100644
index 00000000000..da5c6ebf596
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/progress-line.ts
@@ -0,0 +1,28 @@
+/**
+ * Which progress line the transcript shows.
+ *
+ * There are two candidates: a tool's own line (the pending pill, "Rendering a
+ * card…") and the turn's generic activity ("Thinking…" / "Working…"). Both were
+ * showing at once. The specific line always says more, so it wins and the generic
+ * one stands down — exactly one status line at a time.
+ */
+
+/** A tool call that hasn't produced output yet, so it shows as a pending pill. */
+export const IN_FLIGHT_TOOL_STATES = new Set(["input-streaming", "input-available"]);
+
+type ProgressMessage = { role?: string; parts?: ReadonlyArray };
+
+/** Whether the last turn already shows a tool's own progress line. */
+export function hasToolProgressLine(messages: ReadonlyArray): boolean {
+ const last = messages[messages.length - 1];
+ if (!last || last.role !== "assistant") return false;
+
+ return (last.parts ?? []).some((part) => {
+ const p = part as { type?: string; state?: string };
+ return (
+ typeof p?.type === "string" &&
+ p.type.startsWith("tool-") &&
+ IN_FLIGHT_TOOL_STATES.has(p.state ?? "")
+ );
+ });
+}
diff --git a/apps/webapp/app/components/dashboard-agent/report-block-adapter.test.ts b/apps/webapp/app/components/dashboard-agent/report-block-adapter.test.ts
new file mode 100644
index 00000000000..89a9bb3f066
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/report-block-adapter.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from "vitest";
+import {
+ REPORT_TOOL_PART_TYPE,
+ isReportToolPart,
+ reportBlockFromToolPart,
+ reportIsTrustworthy,
+} from "./report-block-adapter";
+import { blockIdentity, latestRevisionBlocks } from "./view-blocks";
+
+// A trimmed but real-shaped `ReportViewModel`, i.e. exactly what
+// `GET /api/v1/reports/health?format=json` returns.
+const vm = {
+ title: "health",
+ scope: "prod",
+ period: "last 1h",
+ baselineLabel: "vs your 7d normal",
+ generatedAt: "2026-07-27T10:15:00.000Z",
+ windowMinutes: 60,
+ summary: { severity: "crit", statements: [{ findingType: "flow", severity: "crit" }] },
+ findings: [
+ {
+ type: "flow",
+ severity: "crit",
+ reason: "env_limit_saturation",
+ metricIds: ["pending"],
+ recommendation: { code: "raise_env_limit" },
+ },
+ ],
+ metrics: [{ id: "pending", value: 4812, unit: "count", severity: "crit" }],
+ facts: { trustworthy: true },
+ links: [],
+ footer: [{ code: "raise_env_limit" }],
+};
+
+const uri = "trigger://proj_abc/env_abc/report/health";
+
+function part(overrides: Record = {}) {
+ return {
+ type: REPORT_TOOL_PART_TYPE,
+ state: "output-available",
+ toolCallId: "call_1",
+ input: { report: "health" },
+ output: vm,
+ ...overrides,
+ };
+}
+
+describe("reportBlockFromToolPart", () => {
+ it("builds an immutable snapshot keyed by the tool call", () => {
+ const block = reportBlockFromToolPart(part())!;
+ expect(block.type).toBe("report");
+ expect(block.id).toBe("call_1");
+ expect(block.revision).toBe(0);
+ expect(block.asOf).toBe(vm.generatedAt);
+ expect(block.vm).toEqual(vm);
+ expect(block.reportUri).toBeUndefined();
+ });
+
+ it("carries a trigger:// source uri when the tool returned one", () => {
+ expect(reportBlockFromToolPart(part({ output: { vm, uri } }))!.reportUri).toBe(uri);
+ expect(reportBlockFromToolPart(part({ output: { ...vm, reportUri: uri } }))!.reportUri).toBe(
+ uri
+ );
+ });
+
+ it("drops a uri that isn't a valid trigger:// URI, keeping the card", () => {
+ const block = reportBlockFromToolPart(
+ part({ output: { vm, uri: "https://cloud.trigger.dev/report" } })
+ )!;
+ expect(block.reportUri).toBeUndefined();
+ expect(block.vm.title).toBe("health");
+ });
+
+ it("accepts a JSON-string output", () => {
+ expect(reportBlockFromToolPart(part({ output: JSON.stringify(vm) }))!.vm).toEqual(vm);
+ });
+
+ it("returns null for anything that isn't a completed get_report part", () => {
+ expect(reportBlockFromToolPart(part({ type: "tool-run_query" }))).toBeNull();
+ expect(reportBlockFromToolPart(part({ state: "input-available" }))).toBeNull();
+ expect(reportBlockFromToolPart(part({ state: "output-error" }))).toBeNull();
+ expect(reportBlockFromToolPart(part({ toolCallId: undefined }))).toBeNull();
+ expect(reportBlockFromToolPart(undefined)).toBeNull();
+ expect(reportBlockFromToolPart(null)).toBeNull();
+ });
+
+ it("returns null on malformed output instead of throwing", () => {
+ expect(reportBlockFromToolPart(part({ output: undefined }))).toBeNull();
+ expect(reportBlockFromToolPart(part({ output: "not json at all" }))).toBeNull();
+ expect(reportBlockFromToolPart(part({ output: [vm] }))).toBeNull();
+ expect(reportBlockFromToolPart(part({ output: { error: "Unknown report" } }))).toBeNull();
+ // A half-shaped VM is not a report: no severity, no timestamp, nothing to render.
+ expect(reportBlockFromToolPart(part({ output: { title: "health" } }))).toBeNull();
+ expect(reportBlockFromToolPart(part({ output: { ...vm, generatedAt: undefined } }))).toBeNull();
+ });
+
+ it("accepts the curated tool output, which drops links and series", () => {
+ // The `get_report` tool curates its output for the model: same VM fields, but
+ // no `links` and no metric `series` (flagged `seriesOmitted`). The card must
+ // still build — it just renders without sparklines or link targets.
+ const { links, ...curated } = vm;
+ const block = reportBlockFromToolPart(part({ output: { ...curated, seriesOmitted: true } }))!;
+ expect(block.vm.links).toEqual([]);
+ expect(block.vm.metrics[0]!.series).toBeUndefined();
+ expect(block.vm.summary.severity).toBe("crit");
+ });
+
+ it("survives presenter fields it has never seen", () => {
+ const block = reportBlockFromToolPart(
+ part({ output: { ...vm, confidence: "high", newSection: { a: 1 } } })
+ )!;
+ expect((block.vm as Record).confidence).toBe("high");
+ });
+
+ it("renders an untrustworthy (stale telemetry) report, flagged", () => {
+ const stale = { ...vm, facts: { trustworthy: false, flowSource: "runs" } };
+ const block = reportBlockFromToolPart(part({ output: stale }))!;
+ expect(block.vm.facts.trustworthy).toBe(false);
+ expect(reportIsTrustworthy(block.vm)).toBe(false);
+ // Trustworthy by default, including for a snapshot with no facts at all.
+ expect(reportIsTrustworthy({ facts: {} })).toBe(true);
+ expect(reportIsTrustworthy(reportBlockFromToolPart(part())!.vm)).toBe(true);
+ });
+
+ it("never collapses two reports: different tool calls, different blocks", () => {
+ const first = reportBlockFromToolPart(part({ toolCallId: "call_1" }))!;
+ const second = reportBlockFromToolPart(
+ part({ toolCallId: "call_2", output: { ...vm, generatedAt: "2026-07-27T11:15:00.000Z" } })
+ )!;
+ expect(blockIdentity(first)).not.toBe(blockIdentity(second));
+ expect(latestRevisionBlocks([first, second])).toHaveLength(2);
+ // Even the same report fetched twice stays two cards — the snapshot is the point.
+ const sameKeyAgain = reportBlockFromToolPart(part({ toolCallId: "call_3" }))!;
+ expect(latestRevisionBlocks([first, second, sameKeyAgain])).toHaveLength(3);
+ });
+});
+
+describe("isReportToolPart", () => {
+ it("matches a get_report part in any state", () => {
+ expect(isReportToolPart(part({ state: "input-streaming" }))).toBe(true);
+ expect(isReportToolPart(part({ type: "tool-render_view" }))).toBe(false);
+ expect(isReportToolPart(null)).toBe(false);
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/report-block-adapter.ts b/apps/webapp/app/components/dashboard-agent/report-block-adapter.ts
new file mode 100644
index 00000000000..f46fe245bd1
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/report-block-adapter.ts
@@ -0,0 +1,125 @@
+/**
+ * Completed `get_report` tool call -> report block.
+ *
+ * The agent doesn't describe a report, it *fetches* one: `get_report` returns the
+ * whole `ReportViewModel` (the same JSON `GET /api/v1/reports/:key?format=json`
+ * serves). This adapter turns that tool part into a `report` view block so the
+ * card renders the EXACT snapshot the model was grounded on — the model never
+ * restates a number, so the card and the answer can't disagree.
+ *
+ * Deliberately pure and synchronous: no React, no fetch, no clock. Identity comes
+ * from the tool call (`id = toolCallId`, `revision = 0`), which is what makes
+ * every report an immutable historical snapshot — two reports in one conversation
+ * have different ids and therefore never collapse into one card.
+ *
+ * Every failure mode returns `null`. A malformed or half-streamed tool part must
+ * degrade to "no card" (the caller then shows the pending pill or the raw tool
+ * row), never to a crash or a card full of blanks.
+ */
+import {
+ VIEW_BLOCK_VERSION,
+ isTriggerUri,
+ reportBlockSchema,
+ type EnvelopedReportBlock,
+ type ReportViewModelPayload,
+} from "@internal/dashboard-agent-contracts";
+
+/** The tool whose output this adapter understands. */
+export const REPORT_TOOL_PART_TYPE = "tool-get_report";
+
+/** The part shape we read, narrowed by hand — tool parts arrive untyped. */
+type MaybeToolPart = {
+ type?: unknown;
+ state?: unknown;
+ toolCallId?: unknown;
+ output?: unknown;
+};
+
+/** A `get_report` part in any state, including still streaming. */
+export function isReportToolPart(part: unknown): boolean {
+ return (part as MaybeToolPart | null)?.type === REPORT_TOOL_PART_TYPE;
+}
+
+/**
+ * Build the block for a completed `get_report` part, or `null` when this part
+ * isn't one, hasn't finished, or didn't return a usable view model.
+ */
+export function reportBlockFromToolPart(part: unknown): EnvelopedReportBlock | null {
+ const p = (part ?? {}) as MaybeToolPart;
+
+ if (p.type !== REPORT_TOOL_PART_TYPE) return null;
+ // Only a finished call has a snapshot. The in-flight states fall back to the
+ // pending pill, `output-error` to the generic tool row so the failure is visible.
+ if (p.state !== "output-available") return null;
+ // Identity is the tool call's. Without it the block couldn't be keyed stably
+ // across re-renders, so we'd rather render nothing than a card that remounts.
+ if (typeof p.toolCallId !== "string" || p.toolCallId.length === 0) return null;
+
+ const output = normalizeOutput(p.output);
+ if (!output) return null;
+
+ const parsed = reportBlockSchema.safeParse({
+ type: "report",
+ id: p.toolCallId,
+ revision: 0,
+ version: VIEW_BLOCK_VERSION,
+ vm: output.vm,
+ // Only a grammar-valid URI is carried; an invalid one is dropped rather than
+ // failing the whole block, since the card reads fine without it.
+ ...(output.uri !== undefined ? { reportUri: output.uri } : {}),
+ asOf: asOfFrom(output.vm),
+ });
+
+ return parsed.success ? parsed.data : null;
+}
+
+/**
+ * `facts.trustworthy === false` means the telemetry behind the verdict is stale,
+ * so the numbers are informational only. Absent = trustworthy (the common case,
+ * and what pre-`facts` snapshots imply).
+ */
+export function reportIsTrustworthy(vm: Pick): boolean {
+ return vm.facts?.trustworthy !== false;
+}
+
+/**
+ * Pull the view model (and an optional source URI) out of whatever `get_report`
+ * returned. Tolerates the three shapes a tool output realistically takes: the VM
+ * itself, a `{ vm, uri }` wrapper, or a JSON string.
+ */
+function normalizeOutput(output: unknown): { vm: unknown; uri?: string } | null {
+ const value = typeof output === "string" ? tryParseJson(output) : output;
+
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
+
+ const record = value as Record;
+
+ // The route's error shape (`{ error: "…" }`) is not a report.
+ if (typeof record.error === "string") return null;
+
+ const wrapped = record.vm !== undefined && typeof record.vm === "object";
+ const vm = wrapped ? record.vm : record;
+ const uri = firstTriggerUri(record.reportUri, record.uri);
+
+ return { vm, ...(uri === undefined ? {} : { uri }) };
+}
+
+function firstTriggerUri(...candidates: unknown[]): string | undefined {
+ for (const candidate of candidates) {
+ if (typeof candidate === "string" && isTriggerUri(candidate)) return candidate;
+ }
+ return undefined;
+}
+
+/** The snapshot's timestamp is the presenter's, never the renderer's clock. */
+function asOfFrom(vm: unknown): unknown {
+ return (vm as { generatedAt?: unknown } | null)?.generatedAt;
+}
+
+function tryParseJson(value: string): unknown {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx b/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx
new file mode 100644
index 00000000000..db0af2d3afd
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx
@@ -0,0 +1,752 @@
+/**
+ * The parts a report card is built from: the severity vocabulary, the card
+ * chrome (header line, headline, note blocks, footer line), the metric row, and
+ * the sparkline that sits in it.
+ *
+ * Both report cards (the real `ReportView` and the demo mockup) render the same
+ * layout, so it lives here once. The pieces take already-resolved *strings*
+ * rather than a metric object: the two cards read from different (structurally
+ * identical) types and resolve their labels through different message catalogs,
+ * and this way neither concern leaks into the layout.
+ *
+ * PURE, like `ReportView`: no Remix hooks, no loader data, no router context.
+ * Notes use the app's `InfoIconTooltip` primitive and the sparkline reuses the
+ * queue metrics `MiniLineChart`; both are router-agnostic. The footer's
+ * `LinkButton`s are only ever given external URLs, which it renders as plain
+ * anchors — an in-app destination stays an intent, so no router is needed.
+ */
+import {
+ ArrowUpRightIcon,
+ BookOpenIcon,
+ CheckCircleIcon,
+ ExclamationCircleIcon,
+ ExclamationTriangleIcon,
+} from "@heroicons/react/20/solid";
+import { Children, Fragment, type ReactNode } from "react";
+import { Bar, Cell, type TooltipProps } from "recharts";
+import { ActivityBarChart } from "~/components/metrics/ActivityBarChart";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { formatDateTime } from "~/components/primitives/DateTime";
+import { Header3 } from "~/components/primitives/Headers";
+import { InfoIconTooltip } from "~/components/primitives/Tooltip";
+import TooltipPortal from "~/components/primitives/TooltipPortal";
+import { cn } from "~/utils/cn";
+import { AgentStatusIcon, type AgentTone } from "./agent-badges";
+
+/** Both cards' severity type (`Severity` / `ReportSeverity`) resolves to this. */
+export type ReportSeverityKey = "ok" | "warn" | "crit";
+
+// Semantic tokens, not raw palette classes — those are tuned for the dark theme
+// only, and these are what the theme layer remaps (see tailwind.css).
+export const SEVERITY_TEXT: Record = {
+ ok: "text-success",
+ warn: "text-warning",
+ crit: "text-error",
+};
+
+/** The same three colours as CSS values, for the sparkline's line. */
+const SEVERITY_COLOR: Record = {
+ ok: "var(--color-success)",
+ warn: "var(--color-warning)",
+ crit: "var(--color-error)",
+};
+
+const SEVERITY_TONE: Record = {
+ ok: "success",
+ warn: "warning",
+ crit: "error",
+};
+
+const SEVERITY_ICON = {
+ ok: CheckCircleIcon,
+ warn: ExclamationTriangleIcon,
+ crit: ExclamationCircleIcon,
+} as const;
+
+/**
+ * The state marker on a finding or a summary statement. A coloured icon rather
+ * than a coloured dot, so severity survives a glance — same rule the run status
+ * cells follow (`runs/v3/TaskRunStatus`).
+ */
+export function ReportSeverityIcon({
+ severity,
+ className,
+}: {
+ severity: ReportSeverityKey;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+// --- card chrome ------------------------------------------------------------
+
+export function ReportCard({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * The quiet top line: the report's name on the left, its scope / period /
+ * baseline on the right. Anything urgent belongs in the headline below, not here.
+ */
+export function ReportHeaderLine({
+ name,
+ meta,
+ children,
+}: {
+ name: string;
+ meta: string;
+ /** State badges that sit next to the name (e.g. "stale data"). */
+ children?: ReactNode;
+}) {
+ return (
+
+ {name}
+ {children}
+ {meta}
+
+ );
+}
+
+/** The card body. The wide `space-y` is the point — the card is meant to breathe. */
+export function ReportBody({ children, dimmed }: { children: ReactNode; dimmed?: boolean }) {
+ return
{children}
;
+}
+
+/**
+ * The verdict, as one sentence: severity icon, the coloured phrase that names
+ * the state, then a plain continuation that says why.
+ */
+export function ReportHeadline({
+ severity,
+ phrase,
+ continuation,
+}: {
+ severity: ReportSeverityKey;
+ phrase: string;
+ continuation?: string;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * A finding other than the one in the headline: its state, its type, its
+ * reason. Fixed columns, so the texts of consecutive lines (execution /
+ * liveness) start on the same vertical.
+ */
+export function ReportFindingLine({
+ severity,
+ type,
+ text,
+ bright,
+}: {
+ severity: ReportSeverityKey;
+ type: string;
+ text: string;
+ bright?: boolean;
+}) {
+ return (
+
+
+ {type}
+
+ {text}
+
+
+ );
+}
+
+// --- prose highlighting -----------------------------------------------------
+
+/**
+ * The highlight rules for report prose ("why:" lines, "read:" lines). Fixed and
+ * deterministic — the same kind of thing is always emphasized the same way:
+ *
+ * 1. **Quantities** — numbers with their unit (`71%`, `~820/min`, `38 min`,
+ * `6×`) render bright and tabular. Numbers are what the user scans for.
+ * 2. **Entities** — queue/task/run names the caller passes in render mono and
+ * bright, like code.
+ * 3. **Verdict phrases** — the "whose problem is this" answers ("not your
+ * code", "not a code problem", "not the workers") render bright and medium:
+ * emphasis without another color (color stays reserved for severity).
+ * 4. Everything else stays dimmed; causal arrows (→) are structure, not
+ * content, and stay dimmed too.
+ */
+const QUANTITY_RE = /~?\d[\d,.]*\s?(?:%|×|\/min|ms\b|s\b|min\b|h\b)?/g;
+
+const VERDICT_PHRASES = [
+ "not your code",
+ "not a code problem",
+ "not the workers",
+ "not the platform",
+];
+
+type ProseSegment = { text: string; kind: "plain" | "quantity" | "entity" | "verdict" };
+
+function splitBy(
+ segments: ProseSegment[],
+ match: (text: string) => { start: number; end: number } | null,
+ kind: ProseSegment["kind"]
+): ProseSegment[] {
+ return segments.flatMap((segment) => {
+ if (segment.kind !== "plain") return [segment];
+ const out: ProseSegment[] = [];
+ let rest = segment.text;
+ for (;;) {
+ const hit = match(rest);
+ if (!hit) break;
+ if (hit.start > 0) out.push({ text: rest.slice(0, hit.start), kind: "plain" });
+ out.push({ text: rest.slice(hit.start, hit.end), kind });
+ rest = rest.slice(hit.end);
+ }
+ if (rest) out.push({ text: rest, kind: "plain" });
+ return out;
+ });
+}
+
+/** Apply the highlight rules to one resolved prose line. */
+export function ReportProse({ text, entities }: { text: string; entities?: string[] }) {
+ let segments: ProseSegment[] = [{ text, kind: "plain" }];
+
+ for (const entity of entities ?? []) {
+ if (!entity) continue;
+ segments = splitBy(
+ segments,
+ (t) => {
+ const i = t.indexOf(entity);
+ return i === -1 ? null : { start: i, end: i + entity.length };
+ },
+ "entity"
+ );
+ }
+
+ for (const phrase of VERDICT_PHRASES) {
+ segments = splitBy(
+ segments,
+ (t) => {
+ const i = t.toLowerCase().indexOf(phrase);
+ return i === -1 ? null : { start: i, end: i + phrase.length };
+ },
+ "verdict"
+ );
+ }
+
+ segments = splitBy(
+ segments,
+ (t) => {
+ QUANTITY_RE.lastIndex = 0;
+ const m = QUANTITY_RE.exec(t);
+ return m && m[0].trim().length > 0 ? { start: m.index, end: m.index + m[0].length } : null;
+ },
+ "quantity"
+ );
+
+ return (
+ <>
+ {segments.map((segment, i) => {
+ switch (segment.kind) {
+ case "quantity":
+ return (
+
+ {segment.text}
+
+ );
+ case "entity":
+ return (
+
+ {segment.text}
+
+ );
+ case "verdict":
+ return (
+
+ {segment.text}
+
+ );
+ default:
+ return {segment.text};
+ }
+ })}
+ >
+ );
+}
+
+/**
+ * A labelled block of lines — "why:" (what owns the problem, what it isn't) and
+ * "read:" (the plain-language interpretation). The label sits in its own column
+ * so the lines hang together as one indented paragraph.
+ */
+export function ReportNoteBlock({ label, children }: { label: string; children: ReactNode }) {
+ const lines = Children.toArray(children).filter(Boolean);
+ if (lines.length === 0) return null;
+
+ return (
+
+ {label}
+
+ {lines.map((line, i) => (
+
{line}
+ ))}
+
+
+ );
+}
+
+// --- footer -----------------------------------------------------------------
+
+/**
+ * How a footer entry renders. One rule, applied by code rather than by URL, so
+ * the same code always looks the same in both cards:
+ *
+ * - `action` — something happens when you press it: the violet primary button.
+ * Still a button when its href leaves the app (contacting us about a limit is
+ * an action even though it opens a web form); the arrow then says it leaves.
+ * - `docs` — reading matter we wrote: the docs button.
+ * - `reference` — a pointer with no action behind it (a status page): a text
+ * link with the external arrow. A button would promise something happens here.
+ * - `note` — an option stated, not offered: prose.
+ */
+export type ReportFooterStyle = "action" | "docs" | "reference" | "note";
+
+/** Footer codes that state an option rather than offer one. */
+const FOOTER_NOTE_CODES = new Set(["nothing_to_do", "do_nothing_drains", "region_failover"]);
+
+/** Footer codes that only cite a place to look, with nothing to press. */
+const FOOTER_REFERENCE_CODES = new Set(["check_control_plane", "check_platform_status"]);
+
+/** A doc entry names itself one: `concurrency_docs`, `retries_docs`, … */
+const FOOTER_DOCS_SUFFIX = "_docs";
+
+export function reportFooterStyle(code: string): ReportFooterStyle {
+ if (FOOTER_NOTE_CODES.has(code)) return "note";
+ if (code.endsWith(FOOTER_DOCS_SUFFIX)) return "docs";
+ if (FOOTER_REFERENCE_CODES.has(code)) return "reference";
+ return "action";
+}
+
+/**
+ * The footer's recovery-watch offer, which no report emits as a footer entry —
+ * the card adds it. Two codes because it is phrased two ways: as an addendum to
+ * actions the user was just given, and as the only thing on offer when the
+ * report has nothing for them to do.
+ */
+export const FOOTER_WATCH_CODE = "watch_recovery";
+export const FOOTER_WATCH_ONLY_CODE = "watch_recovery_only";
+
+/**
+ * A dimmed line that accompanies a row entry — prose the old sentence footer
+ * carried around the control, kept as a note under the row.
+ */
+const FOOTER_NOTE_LINES: Record = {
+ check_control_plane: "There's nothing to fix on your side.",
+};
+
+/** One resolved footer entry: the code it came from, and what it renders as. */
+export type ReportFooterItem = { code: string; node: ReactNode };
+
+function isRowEntry(item: ReportFooterItem): boolean {
+ const style = reportFooterStyle(item.code);
+ return style === "action" || style === "docs" || style === "reference";
+}
+
+/**
+ * The footer. Every report ends the way the diagnosis card does: a "Next
+ * steps" section heading, the controls (buttons, docs buttons, cited links) in
+ * one wrapping row, and stated options ("or do nothing — …") as a dimmed line
+ * under the row.
+ */
+export function ReportFooterLine({ items }: { items: ReportFooterItem[] }) {
+ const entries = items.filter((item) => item.node);
+ if (entries.length === 0) return null;
+
+ const row = entries.filter(isRowEntry);
+ const noteLines = entries
+ .map((item) => FOOTER_NOTE_LINES[item.code])
+ .filter((line): line is string => Boolean(line));
+ const rest = entries.filter((item) => !isRowEntry(item));
+
+ return (
+
+
Next steps
+ {row.length > 0 ? (
+ // text-xs so a text link in the row (a cited reference) sits at the
+ // same size as the buttons beside it.
+
+ );
+}
+
+/**
+ * A footer entry that only cites a place to look — a status page, a resolved
+ * resource. Underlined text, never a button: a button promises something
+ * happens here.
+ */
+export function ReportFooterLink({
+ href,
+ external,
+ children,
+}: {
+ href: string;
+ external?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+ {external ? (
+
+ ) : null}
+
+ );
+}
+
+/** A footer entry that states an option instead of offering one. */
+export function ReportFooterNote({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+/**
+ * Keeps an `h-6` control on the text baseline inside the footer sentence without
+ * stretching the line it sits on.
+ */
+const INLINE_CONTROL = "inline-flex align-middle";
+
+/** An in-app footer action: it happens here, so it gets the primary button. */
+export function ReportFooterAction({
+ onClick,
+ children,
+}: {
+ onClick: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * A footer action that lives at a URL: the same button, as a link. `docs` gets
+ * the docs variant; an action leaving the app carries the external arrow.
+ */
+export function ReportFooterActionLink({
+ href,
+ docs,
+ children,
+}: {
+ href: string;
+ docs?: boolean;
+ children: ReactNode;
+}) {
+ const external = /^https?:\/\//i.test(href);
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/** Where the snapshot came from, in the report's own URI vocabulary. */
+export function ReportProvenance({ uri }: { uri: string }) {
+ return
{uri}
;
+}
+
+// --- sparkline --------------------------------------------------------------
+
+/** The fixed sparkline column — this is what puts every sparkline on one line. */
+const SPARK_WIDTH_CLASS = "w-[6.5rem]";
+
+/** The chart's own width; the trailing peak label uses the column's remainder. */
+const SPARK_WIDTH = 72;
+
+/** The number of bars a 60-point series is condensed to — chunky, hoverable. */
+const MAX_BARS = 18;
+
+/** Average adjacent points down so each bar is wide enough to read and hover. */
+function condense(points: number[], maxBars: number): number[] {
+ if (points.length <= maxBars) return points;
+ const perBar = points.length / maxBars;
+ return Array.from({ length: maxBars }, (_, i) => {
+ const slice = points.slice(Math.floor(i * perBar), Math.max(Math.floor((i + 1) * perBar), 1));
+ return slice.reduce((sum, v) => sum + v, 0) / Math.max(slice.length, 1);
+ });
+}
+
+type ReportSparkDatum = { count: number; date: Date; hot: boolean };
+
+function ReportSparkTooltip({
+ active,
+ payload,
+ formatPoint,
+}: TooltipProps & { formatPoint: (value: number) => string }) {
+ if (!active || !payload || payload.length === 0) return null;
+ const entry = payload[0].payload as ReportSparkDatum;
+ return (
+
+
+ );
+}
+
+// --- metric row -------------------------------------------------------------
+
+/**
+ * LABEL | value | delta | sparkline, in fixed columns. The outer columns are the
+ * point of the grid: every row's label starts and every row's chart starts on
+ * the same vertical line, whatever the value's width.
+ */
+const METRIC_ROW_CLASS = "grid grid-cols-[7rem_minmax(0,1fr)_2.75rem_6.5rem] items-center gap-x-2";
+
+// Labels are never truncated — the column is sized for the longest label and
+// anything longer wraps to a second line.
+const LABEL_CLASS = "text-xs uppercase leading-tight tracking-wide text-text-dimmed";
+
+/** A metric's movement against its baseline. Direction is an arrow, always. */
+export type ReportDelta = { text: string; dir: "up" | "down" | "flat" };
+
+/**
+ * A view model `Delta` as the row's arrow. A multiplier only reads as movement
+ * once it rounds past 1×; below that, a metric with a baseline is simply flat,
+ * and a metric without one has nothing to compare against.
+ */
+export function reportDelta(
+ delta: { dir: "up" | "down" | "flat"; mult?: number } | undefined,
+ hasBaseline: boolean
+): ReportDelta | undefined {
+ if (delta && delta.mult !== undefined && delta.mult > 1 && delta.dir !== "flat") {
+ return { text: `${delta.dir === "up" ? "↑" : "↓"} ${delta.mult}×`, dir: delta.dir };
+ }
+ return hasBaseline ? { text: "→ flat", dir: "flat" } : undefined;
+}
+
+export function ReportMetricRow({
+ label,
+ value,
+ severity,
+ /** A composite metric's parts, indented under it as their own rows. */
+ subRows,
+ /** The movement against the baseline, e.g. "↑ 6×" or "→ flat". */
+ delta,
+ /**
+ * The row's aside — its baseline, or "estimated". It goes in a tooltip rather
+ * than inline: as trailing text it broke the sparkline column and read like
+ * part of the value.
+ */
+ note,
+ /**
+ * The finding-explaining row's annotation. Joins `note` in the info tooltip —
+ * the fact itself already leads the card's headline, so the row doesn't
+ * repeat it in the flow.
+ */
+ heroNote,
+ series,
+ windowMinutes,
+ anomalyMinutes,
+ formatPoint,
+}: {
+ label: string;
+ value: string;
+ severity: ReportSeverityKey;
+ subRows?: { label: string; value: string }[];
+ delta?: ReportDelta;
+ note?: string;
+ heroNote?: string;
+ series?: number[];
+ windowMinutes: number;
+ anomalyMinutes?: number;
+ formatPoint: (value: number) => string;
+}) {
+ const deltaClass =
+ delta?.dir === "up"
+ ? severity === "ok"
+ ? "text-text-dimmed"
+ : SEVERITY_TEXT[severity]
+ : delta?.dir === "down"
+ ? "text-text-dimmed"
+ : "text-text-faint";
+
+ return (
+ <>
+
+
+ {(subRows ?? []).map((sub) => (
+ // A sub-row keeps the grid's columns: its label sits in the LABEL
+ // column, indented to the middle of the parent label above it, and its
+ // number sits in the VALUE column — on the same vertical as the
+ // parent's +12/min and every other row's value.
+
+ {/* Indented under the parent label, but shallow enough that even
+ "triggered" stays inside the 7rem label column. */}
+ {sub.label}
+
+ {sub.value}
+
+
+ ))}
+ >
+ );
+}
+
+/** The metric grid. Rows are `ReportMetricRow`s, which may expand to several. */
+export function ReportMetricList({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/run-id.test.ts b/apps/webapp/app/components/dashboard-agent/run-id.test.ts
new file mode 100644
index 00000000000..395a80a5718
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/run-id.test.ts
@@ -0,0 +1,34 @@
+import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
+import { describe, expect, it } from "vitest";
+import { isRunFriendlyId } from "./run-id";
+
+describe("isRunFriendlyId", () => {
+ it("matches ids the platform actually mints", () => {
+ for (let i = 0; i < 50; i++) {
+ expect(isRunFriendlyId(generateFriendlyId("run"))).toBe(true);
+ }
+ });
+
+ it("matches a run-ops v1 id (base32hex body + region + version)", () => {
+ expect(isRunFriendlyId("run_0abcdefghijklmnopqrstuvw1")).toBe(true);
+ });
+
+ it("matches a legacy cuid-bodied id", () => {
+ expect(isRunFriendlyId("run_clq1x2y3z0000abcd1efgh2ij")).toBe(true);
+ });
+
+ it("matches an id the user typed in caps", () => {
+ expect(isRunFriendlyId("RUN_ABC123")).toBe(true);
+ });
+
+ it("rejects other entities and non-ids", () => {
+ expect(isRunFriendlyId("error_abc123")).toBe(false);
+ expect(isRunFriendlyId("batch_abc123")).toBe(false);
+ expect(isRunFriendlyId("run_")).toBe(false);
+ expect(isRunFriendlyId("run")).toBe(false);
+ expect(isRunFriendlyId("src/trigger/tasks.ts:42")).toBe(false);
+ expect(isRunFriendlyId("https://example.com/run_abc")).toBe(false);
+ // A hyphen belongs to pod names, never to an id body.
+ expect(isRunFriendlyId("run_abc-attempt-1")).toBe(false);
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/run-id.ts b/apps/webapp/app/components/dashboard-agent/run-id.ts
new file mode 100644
index 00000000000..abe139ad218
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/run-id.ts
@@ -0,0 +1,21 @@
+// Is this string a run's friendly id? Used to decide whether an agent-supplied
+// reference should become a link to a run page.
+//
+// Every friendly run id the platform mints is `run_` + a lowercase alphanumeric
+// body, in all three formats currently in circulation (see
+// `packages/core/src/v3/isomorphic/friendlyId.ts`):
+// - nanoid bodies over `123456789abcdefghijkmnopqrstuvwxyz`
+// - run-ops v1 bodies: 26 chars of lowercase base32hex + region + version
+// - legacy `run_`, cuid being lowercase alphanumeric
+// So one lowercase-alphanumeric pattern covers all of them. It stays
+// case-insensitive because the model may echo an id the user typed in caps, and
+// the run page resolves ids case-insensitively anyway.
+//
+// No length bound on purpose: the three formats have different lengths and a
+// fourth would too. A false positive costs a link that 404s, which is a much
+// smaller failure than silently rendering a real id as dead text.
+export const RUN_FRIENDLY_ID_PATTERN = /^run_[a-z0-9]+$/i;
+
+export function isRunFriendlyId(value: string): boolean {
+ return RUN_FRIENDLY_ID_PATTERN.test(value);
+}
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/dismissal.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/dismissal.ts
new file mode 100644
index 00000000000..c6fe7170344
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/dismissal.ts
@@ -0,0 +1,35 @@
+/**
+ * Per-user chip dismissals.
+ *
+ * localStorage, not a cookie or a DB row: the panel is client-only, a dismissal
+ * is a preference rather than data, and a browser profile is already the unit of
+ * "this user". One key per chip id (rather than one key holding a list) so two
+ * tabs dismissing different chips can't clobber each other's write.
+ */
+const KEY_PREFIX = "tdev:dashboard-agent:prompt-dismissed:";
+
+export const dismissedPromptStorageKey = (promptId: string) => `${KEY_PREFIX}${promptId}`;
+
+/** Every chip id this browser has dismissed. Empty when storage is unavailable. */
+export function readDismissedPromptIds(): string[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const ids: string[] = [];
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const key = window.localStorage.key(i);
+ if (key?.startsWith(KEY_PREFIX)) ids.push(key.slice(KEY_PREFIX.length));
+ }
+ return ids;
+ } catch {
+ return [];
+ }
+}
+
+export function writeDismissedPromptId(promptId: string): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(dismissedPromptStorageKey(promptId), "1");
+ } catch {
+ /* storage full or blocked — the dismissal just doesn't persist */
+ }
+}
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/index.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/index.ts
new file mode 100644
index 00000000000..e17c6f30cb3
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/index.ts
@@ -0,0 +1,36 @@
+/**
+ * The suggested-prompt registry. Import point for the panel and the routes;
+ * nothing here touches the server (the promoted-slot flag reader is a separate
+ * `.server.ts` module, imported only by loaders).
+ */
+export {
+ contextualPrompts,
+ contextualPromptsBySlot,
+ formatAgo,
+ formatMultiplier,
+ GENERIC_PROMPTS,
+ pageDefaultPrompts,
+ pageSlotPrompts,
+ PROMPT_SLOTS,
+ promptForSignal,
+ SIGNAL_PRIORITY,
+ SIGNAL_SLOT,
+ type PageSlotPrompts,
+ type PromptSlot,
+} from "./registry";
+export {
+ makeSuggestedPromptResolver,
+ resolveSuggestedPrompts,
+ type ResolveSuggestedPromptsOptions,
+} from "./resolver";
+export {
+ FRESH_FAILURE_WINDOW_MS,
+ queueAgentPageContext,
+ runAgentPageContext,
+} from "./page-mappers";
+export { parsePromotedPrompt } from "./promoted";
+export {
+ dismissedPromptStorageKey,
+ readDismissedPromptIds,
+ writeDismissedPromptId,
+} from "./dismissal";
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.test.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.test.ts
new file mode 100644
index 00000000000..16162a12c3a
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.test.ts
@@ -0,0 +1,237 @@
+import { agentPageContextSchema } from "@internal/dashboard-agent-contracts";
+import { describe, expect, it } from "vitest";
+import {
+ FRESH_FAILURE_WINDOW_MS,
+ queueAgentPageContext,
+ runAgentPageContext,
+} from "./page-mappers";
+
+const NOW = Date.parse("2026-07-27T10:25:41.000Z");
+
+/** The run-detail loader payload, as it arrives on the route match (JSON, so ISO dates). */
+function runLoaderData(
+ overrides: {
+ status?: string;
+ completedAt?: string | null;
+ friendlyId?: string;
+ taskId?: string;
+ events?: unknown[];
+ } = {}
+) {
+ const friendlyId = overrides.friendlyId ?? "run_abc123";
+ return {
+ run: {
+ friendlyId,
+ status: overrides.status ?? "EXECUTING",
+ completedAt: overrides.completedAt ?? null,
+ // Fields the real loader also returns, to prove the mapper ignores them.
+ startedAt: "2026-07-27T10:10:00.000Z",
+ isFinished: false,
+ environment: { id: "env_1", organizationId: "org_1" },
+ },
+ trace: {
+ events:
+ overrides.events ??
+ ([
+ {
+ id: "span_1",
+ runId: friendlyId,
+ data: { message: overrides.taskId ?? "process-order" },
+ },
+ ] as unknown[]),
+ },
+ runsList: null,
+ };
+}
+
+describe("runAgentPageContext", () => {
+ it("classifies the page and names the run, its status and its task", () => {
+ const context = runAgentPageContext(runLoaderData(), NOW);
+
+ expect(context).toMatchObject({
+ page: {
+ kind: "run",
+ runId: "run_abc123",
+ status: "Executing",
+ taskId: "process-order",
+ },
+ signals: [],
+ });
+ expect(agentPageContextSchema.safeParse(context).success).toBe(true);
+ });
+
+ it("emits fresh_failure for a run that just failed", () => {
+ const completedAt = new Date(NOW - 5 * 60_000).toISOString();
+ const context = runAgentPageContext(
+ runLoaderData({ status: "COMPLETED_WITH_ERRORS", completedAt }),
+ NOW
+ );
+
+ expect(context?.page).toMatchObject({ status: "Failed" });
+ expect(context?.signals).toEqual([
+ { kind: "fresh_failure", runId: "run_abc123", failedAt: completedAt },
+ ]);
+ });
+
+ it.each(["CRASHED", "SYSTEM_FAILURE", "TIMED_OUT", "EXPIRED"])(
+ "treats %s as a failure",
+ (status) => {
+ const context = runAgentPageContext(
+ runLoaderData({ status, completedAt: new Date(NOW - 60_000).toISOString() }),
+ NOW
+ );
+ expect(context?.signals[0]?.kind).toBe("fresh_failure");
+ }
+ );
+
+ it("emits no signal for a failure outside the freshness window", () => {
+ const context = runAgentPageContext(
+ runLoaderData({
+ status: "COMPLETED_WITH_ERRORS",
+ completedAt: new Date(NOW - FRESH_FAILURE_WINDOW_MS - 1000).toISOString(),
+ }),
+ NOW
+ );
+
+ expect(context?.signals).toEqual([]);
+ });
+
+ it("emits no signal for a failure with no timestamp", () => {
+ const context = runAgentPageContext(
+ runLoaderData({ status: "CRASHED", completedAt: null }),
+ NOW
+ );
+ expect(context?.signals).toEqual([]);
+ });
+
+ it.each([
+ ["PENDING", "Queued"],
+ ["DELAYED", "Delayed"],
+ ["PENDING_VERSION", "Pending version"],
+ ])("emits waiting_run for %s", (status, label) => {
+ const context = runAgentPageContext(runLoaderData({ status }), NOW);
+
+ expect(context?.page).toMatchObject({ status: label });
+ expect(context?.signals).toEqual([{ kind: "waiting_run", runId: "run_abc123" }]);
+ });
+
+ it("emits nothing for a healthy finished run", () => {
+ const context = runAgentPageContext(
+ runLoaderData({ status: "COMPLETED_SUCCESSFULLY", completedAt: new Date(NOW).toISOString() }),
+ NOW
+ );
+
+ expect(context?.page).toMatchObject({ status: "Completed" });
+ expect(context?.signals).toEqual([]);
+ });
+
+ it("takes the task id off this run's own span, not another run's", () => {
+ const context = runAgentPageContext(
+ runLoaderData({
+ events: [
+ { id: "span_root", runId: "run_parent", data: { message: "parent-task" } },
+ { id: "span_1", runId: "run_abc123", data: { message: "child-task" } },
+ ],
+ }),
+ NOW
+ );
+
+ expect(context?.page).toMatchObject({ taskId: "child-task" });
+ });
+
+ it("falls back to the anchor span when no span carries this run's id", () => {
+ const context = runAgentPageContext(
+ runLoaderData({ events: [{ id: "span_1", data: { message: "orphan-task" } }] }),
+ NOW
+ );
+
+ expect(context?.page).toMatchObject({ taskId: "orphan-task" });
+ });
+
+ it("returns undefined rather than invent a task id", () => {
+ expect(runAgentPageContext(runLoaderData({ events: [] }), NOW)).toBeUndefined();
+ expect(
+ runAgentPageContext({ run: { friendlyId: "run_x", status: "PENDING" } }, NOW)
+ ).toBeUndefined();
+ });
+
+ it("returns undefined for data it doesn't recognise", () => {
+ expect(runAgentPageContext(undefined, NOW)).toBeUndefined();
+ expect(runAgentPageContext(null, NOW)).toBeUndefined();
+ expect(runAgentPageContext({ nope: true }, NOW)).toBeUndefined();
+ });
+});
+
+function queueLoaderData(queue: Partial> = {}) {
+ return {
+ queue: {
+ id: "queue_1",
+ name: "black-friday",
+ type: "custom",
+ paused: false,
+ running: 1,
+ queued: 0,
+ concurrencyLimit: 10,
+ ...queue,
+ },
+ fullName: "black-friday",
+ };
+}
+
+describe("queueAgentPageContext", () => {
+ it("names the queue and reports it healthy when nothing is waiting", () => {
+ const context = queueAgentPageContext(queueLoaderData());
+
+ expect(context).toEqual({
+ page: { kind: "queue", name: "black-friday", health: "ok" },
+ signals: [],
+ });
+ expect(agentPageContextSchema.safeParse(context).success).toBe(true);
+ });
+
+ it("emits no saturation signal when the queue is idle under its limit", () => {
+ const context = queueAgentPageContext(queueLoaderData({ running: 10, queued: 0 }));
+ expect(context?.signals).toEqual([]);
+ });
+
+ it("warns on a backlog that is not yet at capacity", () => {
+ const context = queueAgentPageContext(queueLoaderData({ running: 4, queued: 6 }));
+
+ expect(context?.page).toMatchObject({ health: "warn" });
+ expect(context?.signals).toEqual([]);
+ });
+
+ it("emits concurrency_saturation at capacity with work waiting", () => {
+ const context = queueAgentPageContext(queueLoaderData({ running: 10, queued: 3 }));
+
+ expect(context?.page).toMatchObject({ health: "crit" });
+ expect(context?.signals).toEqual([{ kind: "concurrency_saturation", severity: "warn" }]);
+ });
+
+ it("escalates to crit severity when the backlog is deeper than the limit", () => {
+ const context = queueAgentPageContext(queueLoaderData({ running: 10, queued: 40 }));
+
+ expect(context?.signals).toEqual([{ kind: "concurrency_saturation", severity: "crit" }]);
+ });
+
+ it("treats a paused queue as a warning with no saturation signal", () => {
+ const context = queueAgentPageContext(queueLoaderData({ paused: true, running: 0, queued: 5 }));
+
+ expect(context?.page).toMatchObject({ health: "warn" });
+ expect(context?.signals).toEqual([]);
+ });
+
+ it("emits nothing for an unlimited queue, however deep the backlog", () => {
+ const context = queueAgentPageContext(
+ queueLoaderData({ concurrencyLimit: null, running: 99, queued: 99 })
+ );
+
+ expect(context?.page).toMatchObject({ health: "warn" });
+ expect(context?.signals).toEqual([]);
+ });
+
+ it("returns undefined for data it doesn't recognise", () => {
+ expect(queueAgentPageContext(undefined)).toBeUndefined();
+ expect(queueAgentPageContext({ queue: { name: "x" } })).toBeUndefined();
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.ts
new file mode 100644
index 00000000000..a4b5d59d1c3
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.ts
@@ -0,0 +1,197 @@
+/**
+ * Route `handle` mappers: loader data in, `AgentPageContext` out.
+ *
+ * These live here rather than inline in the routes so they can be unit tested as
+ * plain functions — a route module drags in the whole page. Each one is a pure
+ * function of data the loader ALREADY returns; none of them may cause a query,
+ * and a field that isn't in the loader means the signal simply isn't emitted.
+ *
+ * They read raw route-match data (`useAgentPageContext` passes `match.data`
+ * straight through), so dates arrive as ISO strings, not `Date`s — hence the
+ * string|Date unions. Everything is `safeParse`d: a shape change downstream
+ * degrades to "no context", never a thrown mapper.
+ *
+ * A signal is emitted ONLY for abnormal state. That's the contract the resolver
+ * relies on: if a contextual chip is on screen, something is actually up.
+ */
+import type { AgentPageContext, AgentPageSignal } from "@internal/dashboard-agent-contracts";
+import { z } from "zod";
+
+/**
+ * How recently a run must have failed for the failure to be "why the user is
+ * here". Older failures still show the run page's defaults.
+ */
+export const FRESH_FAILURE_WINDOW_MS = 30 * 60_000;
+
+/**
+ * Run statuses that mean "this failed". Mirrors the error set the run presenter
+ * uses for the root span (`isError`).
+ */
+const FAILED_STATUSES = new Set([
+ "COMPLETED_WITH_ERRORS",
+ "CRASHED",
+ "SYSTEM_FAILURE",
+ "TIMED_OUT",
+ "EXPIRED",
+]);
+
+/** Run statuses that mean "queued or delayed, not executing" (`QUEUED_STATUSES`). */
+const WAITING_STATUSES = new Set(["PENDING", "PENDING_VERSION", "WAITING_FOR_DEPLOY", "DELAYED"]);
+
+/**
+ * Display labels for run statuses, mirroring `runStatusTitleFromStatus` in
+ * `components/runs/v3/TaskRunStatus.tsx`. Duplicated rather than imported so
+ * this module stays free of React component imports; the contract stores the
+ * status as a plain string, so an unmapped status passes through as-is.
+ */
+const STATUS_LABELS: Record = {
+ DELAYED: "Delayed",
+ PENDING: "Queued",
+ PENDING_VERSION: "Pending version",
+ WAITING_FOR_DEPLOY: "Pending version",
+ DEQUEUED: "Dequeued",
+ EXECUTING: "Executing",
+ WAITING_TO_RESUME: "Waiting",
+ RETRYING_AFTER_FAILURE: "Reattempting",
+ PAUSED: "Paused",
+ CANCELED: "Canceled",
+ INTERRUPTED: "Interrupted",
+ COMPLETED_SUCCESSFULLY: "Completed",
+ COMPLETED_WITH_ERRORS: "Failed",
+ SYSTEM_FAILURE: "System failure",
+ CRASHED: "Crashed",
+ EXPIRED: "Expired",
+ TIMED_OUT: "Timed out",
+};
+
+const dateish = z.union([z.string(), z.date()]).nullish();
+
+function toMillis(value: string | Date | null | undefined): number | undefined {
+ if (!value) return undefined;
+ const ms = value instanceof Date ? value.getTime() : Date.parse(value);
+ return Number.isNaN(ms) ? undefined : ms;
+}
+
+function toIso(value: string | Date | null | undefined): string | undefined {
+ const ms = toMillis(value);
+ return ms === undefined ? undefined : new Date(ms).toISOString();
+}
+
+// ---------------------------------------------------------------------------
+// Run detail page
+// ---------------------------------------------------------------------------
+
+/**
+ * What the run-detail loader gives us. `run.taskIdentifier` isn't in the payload,
+ * so the task id comes off the run's own span in the trace — the same span the
+ * page's tree renders, whose `message` is the task identifier.
+ */
+const runLoaderDataSchema = z.object({
+ run: z.object({
+ friendlyId: z.string(),
+ status: z.string(),
+ completedAt: dateish,
+ }),
+ trace: z
+ .object({
+ events: z
+ .array(
+ z.object({
+ runId: z.string().nullish(),
+ data: z.object({ message: z.string().nullish() }).nullish(),
+ })
+ )
+ .nullish(),
+ })
+ .nullish(),
+});
+
+export function runAgentPageContext(
+ data: unknown,
+ now: number = Date.now()
+): AgentPageContext | undefined {
+ const parsed = runLoaderDataSchema.safeParse(data);
+ if (!parsed.success) return undefined;
+
+ const { run, trace } = parsed.data;
+ const events = trace?.events ?? [];
+ // The run's own span first; the anchor span is events[0] in practice, but a
+ // truncated trace can shift it.
+ const taskId =
+ events.find((event) => event.runId === run.friendlyId && event.data?.message)?.data?.message ??
+ events[0]?.data?.message;
+
+ // No task id means we'd be inventing one to satisfy the contract. Returning
+ // undefined lets the hook fall back to the path, which is honest.
+ if (!taskId) return undefined;
+
+ const signals: AgentPageSignal[] = [];
+
+ if (FAILED_STATUSES.has(run.status)) {
+ const failedAtMs = toMillis(run.completedAt);
+ // A failure with no timestamp can't be called fresh, so it isn't.
+ if (failedAtMs !== undefined && now - failedAtMs <= FRESH_FAILURE_WINDOW_MS) {
+ signals.push({
+ kind: "fresh_failure",
+ runId: run.friendlyId,
+ failedAt: toIso(run.completedAt)!,
+ });
+ }
+ } else if (WAITING_STATUSES.has(run.status)) {
+ // The queue name isn't in this loader's payload, so it's omitted rather than
+ // fetched.
+ signals.push({ kind: "waiting_run", runId: run.friendlyId });
+ }
+
+ // No `slow_run`: the loader has this run's duration but no per-task p95
+ // baseline, and fetching one would be an added query.
+
+ return {
+ page: {
+ kind: "run",
+ runId: run.friendlyId,
+ status: STATUS_LABELS[run.status] ?? run.status,
+ taskId,
+ },
+ signals,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Queue detail page
+// ---------------------------------------------------------------------------
+
+const queueLoaderDataSchema = z.object({
+ queue: z.object({
+ name: z.string(),
+ paused: z.boolean().nullish(),
+ running: z.number(),
+ queued: z.number(),
+ concurrencyLimit: z.number().nullish(),
+ }),
+});
+
+/**
+ * Queue health, from the same running/queued/limit decision the queues list
+ * renders as a badge (`queueHealthLabel`): at capacity with work waiting is
+ * critical, a backlog under the limit is a warning, paused is a warning.
+ */
+export function queueAgentPageContext(data: unknown): AgentPageContext | undefined {
+ const parsed = queueLoaderDataSchema.safeParse(data);
+ if (!parsed.success) return undefined;
+
+ const { name, paused, running, queued, concurrencyLimit } = parsed.data.queue;
+ const limit = concurrencyLimit ?? null;
+ const atCapacity = limit !== null && limit > 0 && running >= limit && queued > 0;
+
+ const health = atCapacity ? "crit" : paused || queued > 0 ? "warn" : "ok";
+
+ const signals: AgentPageSignal[] = [];
+ if (atCapacity) {
+ // A backlog at least as deep as the limit is a queue that won't clear this
+ // cycle — that's the crit case.
+ signals.push({ kind: "concurrency_saturation", severity: queued >= limit! ? "crit" : "warn" });
+ }
+
+ return { page: { kind: "queue", name, health }, signals };
+}
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/promoted.test.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/promoted.test.ts
new file mode 100644
index 00000000000..8ec8d0d3406
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/promoted.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { parsePromotedPrompt } from "./promoted";
+
+const valid = JSON.stringify({
+ id: "sp:promo-blackfriday",
+ label: "Check the queue",
+ prompt: "How is the black-friday queue holding up?",
+});
+
+describe("parsePromotedPrompt", () => {
+ it("reads a chip out of the flag value and marks it promoted", () => {
+ expect(parsePromotedPrompt(valid)).toEqual({
+ id: "sp:promo-blackfriday",
+ label: "Check the queue",
+ prompt: "How is the black-friday queue holding up?",
+ source: "promoted",
+ });
+ });
+
+ it("forces the promoted source even when the value claims otherwise", () => {
+ const value = JSON.stringify({ id: "a", label: "b", prompt: "c", source: "default" });
+ expect(parsePromotedPrompt(value)?.source).toBe("promoted");
+ });
+
+ it("ignores anything malformed", () => {
+ for (const value of [
+ undefined,
+ null,
+ "",
+ " ",
+ "not json",
+ "{}",
+ JSON.stringify({ id: "a", label: "b" }),
+ JSON.stringify({ id: "", label: "b", prompt: "c" }),
+ JSON.stringify([{ id: "a", label: "b", prompt: "c" }]),
+ JSON.stringify("a string"),
+ 42,
+ ]) {
+ expect(parsePromotedPrompt(value), String(value)).toBeUndefined();
+ }
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/promoted.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/promoted.ts
new file mode 100644
index 00000000000..955eb9731c7
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/promoted.ts
@@ -0,0 +1,32 @@
+/**
+ * Parsing the promoted-slot flag value.
+ *
+ * Split out of `promotedPrompt.server.ts` so the parsing (the part with rules
+ * worth testing) doesn't drag in prisma and `env.server`.
+ */
+import { suggestedPromptSchema, type SuggestedPrompt } from "@internal/dashboard-agent-contracts";
+
+/** `source` is set here, so the stored JSON doesn't have to carry it. */
+const promotedPromptSchema = suggestedPromptSchema.omit({ source: true }).extend({
+ id: suggestedPromptSchema.shape.id.min(1),
+ label: suggestedPromptSchema.shape.label.min(1),
+ prompt: suggestedPromptSchema.shape.prompt.min(1),
+});
+
+/**
+ * The promoted chip from a stored flag value, or undefined for anything
+ * malformed — a typo in the admin field must cost a chip, not the panel.
+ */
+export function parsePromotedPrompt(value: unknown): SuggestedPrompt | undefined {
+ if (typeof value !== "string" || value.trim() === "") return undefined;
+
+ let json: unknown;
+ try {
+ json = JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+
+ const parsed = promotedPromptSchema.safeParse(json);
+ return parsed.success ? { ...parsed.data, source: "promoted" } : undefined;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/promotedPrompt.server.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/promotedPrompt.server.ts
new file mode 100644
index 00000000000..01c1ddaf307
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/promotedPrompt.server.ts
@@ -0,0 +1,36 @@
+/**
+ * The promoted chip slot: one product-controlled suggested prompt, swappable
+ * without a deploy.
+ *
+ * Stored in the `promotedDashboardAgentPrompt` feature flag as a JSON *string*.
+ * The flag catalog is deliberately scalar-only (its admin UI renders each flag
+ * from `getFlagControlType`, which knows booleans, enums, numbers and strings),
+ * so keeping the value a string leaves the catalog and its UI untouched and puts
+ * the structure in a schema validated at read time (`./promoted`). Set globally
+ * or per-org, org wins — same mechanism as `hasDashboardAgentAccess`.
+ *
+ * The value to paste into the flag:
+ * {"id":"sp:promo-blackfriday","label":"Check the queue","prompt":"How is the black-friday queue holding up?"}
+ */
+import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
+import { FEATURE_FLAG } from "~/v3/featureFlags";
+import { makeFlag } from "~/v3/featureFlags.server";
+import { parsePromotedPrompt } from "./promoted";
+
+/**
+ * The promoted chip for this org, or undefined when none is configured.
+ * `orgFeatureFlags` is the org's `featureFlags` column when the caller already
+ * has it (a layout loader that queried the org with a membership check), so a
+ * per-org promoted chip costs no extra lookup.
+ */
+export async function getPromotedDashboardAgentPrompt(options?: {
+ orgFeatureFlags?: Record | null;
+}): Promise {
+ const flag = makeFlag();
+ const value = await flag({
+ key: FEATURE_FLAG.promotedDashboardAgentPrompt,
+ overrides: options?.orgFeatureFlags ?? {},
+ });
+
+ return parsePromotedPrompt(value);
+}
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/registry.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/registry.ts
new file mode 100644
index 00000000000..6d44f61beca
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/registry.ts
@@ -0,0 +1,320 @@
+/**
+ * The suggested-prompt registry: the chips the panel can offer, and the rules
+ * that pick them.
+ *
+ * The row is a discoverability surface, so it's built from fixed slots rather
+ * than a ranked pile:
+ *
+ * - `investigate` — "something is wrong, dig in". Filled by a fresh_failure or
+ * slow_run signal, or by the page kind when the page is inherently about a
+ * failure (an error or a single run).
+ * - `watch` — "tell me when this changes". Filled by a waiting_run or
+ * saturation signal, or by a queue/error page.
+ * - `explain` — the evergreen explain/find/show question. Always present.
+ * - `docs` — a doc-flavored "how do I …". Always present, always last.
+ *
+ * A signal only exists for abnormal state (the route `handle` mappers enforce
+ * that), so an `investigate`/`watch` chip appearing is itself the news. Wording
+ * follows the demo fixtures (`demo/fixtures/page-context.ts`), which is the
+ * review-approved copy.
+ *
+ * `resolver.ts` orders the slots and applies the cap. This file is pure: no
+ * React, no server imports, no clock beyond the `now` passed in.
+ */
+import type {
+ AgentPage,
+ AgentPageContext,
+ AgentPageSignal,
+ AgentPageSignalKind,
+ SuggestedPrompt,
+} from "@internal/dashboard-agent-contracts";
+
+/**
+ * Chip ids are stable and carry no run/queue identity, on purpose: a dismissal
+ * is stored by id, and an id like `fresh-failure:run_abc` would make "don't show
+ * me this again" mean "don't show me this again for this one run" — which is the
+ * same as not persisting at all. Identity lives in the prompt text instead.
+ */
+const ID_PREFIX = "sp";
+
+function make(
+ id: string,
+ label: string,
+ prompt: string,
+ source: SuggestedPrompt["source"]
+): SuggestedPrompt {
+ return { id: `${ID_PREFIX}:${id}`, label, prompt, source };
+}
+
+const def = (id: string, label: string, prompt: string) => make(id, label, prompt, "default");
+const ctx = (id: string, label: string, prompt: string) => make(id, label, prompt, "contextual");
+
+/** The slots after the promoted one, in display order. */
+export const PROMPT_SLOTS = ["investigate", "watch", "explain", "docs"] as const;
+
+export type PromptSlot = (typeof PROMPT_SLOTS)[number];
+
+/**
+ * The slot a page kind can fill. `explain` and `docs` are required — every page
+ * must be able to fill the last two slots.
+ */
+export type PageSlotPrompts = {
+ investigate?: SuggestedPrompt;
+ watch?: SuggestedPrompt;
+ explain: SuggestedPrompt;
+ docs: SuggestedPrompt;
+};
+
+// ---------------------------------------------------------------------------
+// Page-independent chips, for pages we haven't classified.
+// ---------------------------------------------------------------------------
+
+const EXPLAIN_PAGE = def(
+ "explain-page",
+ "Explain this page",
+ "Explain what this page shows and what I can do here."
+);
+const DOCS_GENERIC = def(
+ "docs-generic",
+ "How do I use Trigger.dev?",
+ "How do I get started with Trigger.dev? Point me at the docs."
+);
+const DOCS_RETRIES = def(
+ "docs-retries",
+ "How do retries work?",
+ "How do retries work in Trigger.dev?"
+);
+
+/** The slots an unclassified page fills: explain, then docs. */
+export const GENERIC_PROMPTS: SuggestedPrompt[] = [EXPLAIN_PAGE, DOCS_GENERIC];
+
+// ---------------------------------------------------------------------------
+// Page-kind slots. What to ask on this kind of page when nothing is wrong.
+// ---------------------------------------------------------------------------
+
+/** The slot chips a page kind can fill, before signals and the promoted slot. */
+export function pageSlotPrompts(page: AgentPage): PageSlotPrompts {
+ switch (page.kind) {
+ case "runs":
+ return {
+ explain: def(
+ "runs-failing-most",
+ "Show me failed runs from today",
+ "Which tasks are failing most in the last 24 hours?"
+ ),
+ docs: def(
+ "docs-run-filters",
+ "How do I filter runs?",
+ "How do I filter and search runs in Trigger.dev?"
+ ),
+ };
+
+ case "run":
+ return {
+ investigate: def(
+ "run-investigate",
+ "What happened in this run?",
+ `Investigate ${page.runId} — walk me through what happened.`
+ ),
+ explain: def(
+ "run-task-health",
+ "Is this task healthy?",
+ `How has ${page.taskId} been performing recently?`
+ ),
+ docs: DOCS_RETRIES,
+ };
+
+ case "error":
+ return {
+ investigate: def(
+ "error-cause",
+ "What's causing this error?",
+ "Investigate this error — what's causing it and which runs are affected?"
+ ),
+ watch: def(
+ "error-watch-recurrence",
+ "Tell me if it comes back",
+ "Watch this error and tell me if it happens again."
+ ),
+ explain: def(
+ "error-similar",
+ "Find similar failures",
+ "Find other failures that look like this one."
+ ),
+ docs: def(
+ "docs-errors",
+ "How do I handle errors?",
+ "How do I catch and handle errors in Trigger.dev tasks?"
+ ),
+ };
+
+ case "queue":
+ return {
+ watch: def(
+ "queue-watch-drain",
+ "Tell me when the backlog drains",
+ `Watch the ${page.name} queue and tell me when the backlog drains.`
+ ),
+ explain: def(
+ "queue-state",
+ "How is this queue doing?",
+ `Explain the current state of the ${page.name} queue.`
+ ),
+ docs: def(
+ "docs-concurrency",
+ "How does concurrency work?",
+ "How do queues and concurrency limits work in Trigger.dev?"
+ ),
+ };
+
+ case "deployment":
+ return {
+ explain: def(
+ "deployment-diff",
+ "What changed in this deploy?",
+ `What changed in deployment ${page.version}?`
+ ),
+ docs: def(
+ "docs-deploys",
+ "How do deploys work?",
+ "How do deployments and versions work in Trigger.dev?"
+ ),
+ };
+
+ case "other":
+ return { explain: EXPLAIN_PAGE, docs: DOCS_GENERIC };
+ }
+}
+
+/**
+ * The page's slot chips as a flat list in display order. Slots the page can't
+ * fill are simply absent — the row collapses rather than padding itself.
+ */
+export function pageDefaultPrompts(page: AgentPage): SuggestedPrompt[] {
+ const slots = pageSlotPrompts(page);
+ return PROMPT_SLOTS.map((slot) => slots[slot]).filter(
+ (prompt): prompt is SuggestedPrompt => prompt !== undefined
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Contextual prompts, one per signal.
+// ---------------------------------------------------------------------------
+
+/** Which slot a signal's chip competes for. */
+export const SIGNAL_SLOT: Record = {
+ fresh_failure: "investigate",
+ slow_run: "investigate",
+ waiting_run: "watch",
+ concurrency_saturation: "watch",
+};
+
+/**
+ * Signal precedence within a slot. `fresh_failure` wins: a run that just failed
+ * is why the user opened the panel. Mirrors `demoSignalsByPriority` in the
+ * fixtures.
+ */
+export const SIGNAL_PRIORITY: AgentPageSignalKind[] = [
+ "fresh_failure",
+ "waiting_run",
+ "slow_run",
+ "concurrency_saturation",
+];
+
+/** "3m", "2h", "4d" — coarse on purpose, this is chip copy. */
+export function formatAgo(ms: number): string {
+ if (ms < 60_000) return "moments";
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
+ return `${Math.round(ms / 86_400_000)}d`;
+}
+
+/** "2.4x" under 10x, "31x" above — one decimal only where it means something. */
+export function formatMultiplier(factor: number): string {
+ return factor < 10 ? `${factor.toFixed(1)}x` : `${Math.round(factor)}x`;
+}
+
+/**
+ * The chip for one signal, or undefined when the signal doesn't carry enough to
+ * say anything useful (a slow_run with no baseline, say).
+ */
+export function promptForSignal(signal: AgentPageSignal, now: number): SuggestedPrompt | undefined {
+ switch (signal.kind) {
+ case "fresh_failure": {
+ const failedAt = Date.parse(signal.failedAt);
+ const ago = Number.isNaN(failedAt) ? undefined : formatAgo(Math.max(0, now - failedAt));
+ return ctx(
+ "fresh-failure",
+ "Why did this run fail?",
+ ago
+ ? `Investigate ${signal.runId} — it failed ${ago} ago. What went wrong?`
+ : `Investigate why ${signal.runId} failed.`
+ );
+ }
+
+ case "waiting_run":
+ return ctx(
+ "waiting-run",
+ "Tell me when this run starts",
+ signal.queue
+ ? `Watch ${signal.runId} and tell me when it leaves the ${signal.queue} queue.`
+ : `Watch ${signal.runId} and tell me when it starts running.`
+ );
+
+ case "slow_run": {
+ if (signal.baselineP95Ms <= 0) return undefined;
+ const factor = formatMultiplier(signal.durationMs / signal.baselineP95Ms);
+ return ctx(
+ "slow-run",
+ `~${factor} slower than usual`,
+ `${signal.runId} is running ~${factor} slower than this task's usual p95. Investigate why.`
+ );
+ }
+
+ case "concurrency_saturation":
+ return ctx(
+ "concurrency-saturation",
+ "Tell me when the backlog drains",
+ "Concurrency is saturated right now. Watch it and tell me when the backlog drains."
+ );
+ }
+}
+
+/** Contextual chips for a context's signals, in precedence order. */
+export function contextualPrompts(context: AgentPageContext, now: number): SuggestedPrompt[] {
+ const prompts: SuggestedPrompt[] = [];
+ for (const kind of SIGNAL_PRIORITY) {
+ for (const signal of context.signals) {
+ if (signal.kind !== kind) continue;
+ const prompt = promptForSignal(signal, now);
+ if (prompt) prompts.push(prompt);
+ }
+ }
+ return prompts;
+}
+
+/**
+ * Contextual chips grouped by the slot they compete for, each group in
+ * precedence order.
+ */
+export function contextualPromptsBySlot(
+ context: AgentPageContext,
+ now: number
+): Record {
+ const bySlot: Record = {
+ investigate: [],
+ watch: [],
+ explain: [],
+ docs: [],
+ };
+
+ for (const kind of SIGNAL_PRIORITY) {
+ for (const signal of context.signals) {
+ if (signal.kind !== kind) continue;
+ const prompt = promptForSignal(signal, now);
+ if (prompt) bySlot[SIGNAL_SLOT[kind]].push(prompt);
+ }
+ }
+
+ return bySlot;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.test.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.test.ts
new file mode 100644
index 00000000000..3406664e04c
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.test.ts
@@ -0,0 +1,231 @@
+import { SUGGESTED_PROMPT_CAP, type SuggestedPrompt } from "@internal/dashboard-agent-contracts";
+import { describe, expect, it } from "vitest";
+import { demoFreshFailureSignal, demoPageContexts } from "../demo/fixtures/page-context";
+import { GENERIC_PROMPTS, pageDefaultPrompts, pageSlotPrompts } from "./registry";
+import { makeSuggestedPromptResolver, resolveSuggestedPrompts } from "./resolver";
+
+/** Fixed clock: "failed 12 minutes after the fixture's failedAt". */
+const NOW = Date.parse("2026-07-27T10:25:41.000Z");
+
+const promoted: SuggestedPrompt = {
+ id: "sp:promo-blackfriday",
+ label: "Check the Black Friday queue",
+ prompt: "How is the black-friday queue holding up?",
+ source: "default", // deliberately wrong — the resolver must force `promoted`
+};
+
+const ids = (prompts: SuggestedPrompt[]) => prompts.map((p) => p.id);
+
+/** The docs chip for a page — always the last slot. */
+const docsId = (key: keyof typeof demoPageContexts) =>
+ pageSlotPrompts(demoPageContexts[key].page).docs.id;
+
+describe("resolveSuggestedPrompts", () => {
+ it("fills all five slots when the page has a promoted chip, signals and defaults", () => {
+ // Error page: investigate + watch + explain + docs defaults, plus a fresh
+ // failure signal that takes the investigate slot.
+ const prompts = resolveSuggestedPrompts(demoPageContexts.error, { promoted, now: NOW });
+
+ expect(prompts).toHaveLength(5);
+ expect(ids(prompts)).toEqual([
+ promoted.id,
+ "sp:fresh-failure",
+ "sp:error-watch-recurrence",
+ "sp:error-similar",
+ docsId("error"),
+ ]);
+ expect(prompts[0]?.source).toBe("promoted");
+ });
+
+ it("drops to four slots when nothing is promoted", () => {
+ const prompts = resolveSuggestedPrompts(demoPageContexts.error, { now: NOW });
+
+ expect(prompts).toHaveLength(4);
+ expect(ids(prompts)).toEqual([
+ "sp:fresh-failure",
+ "sp:error-watch-recurrence",
+ "sp:error-similar",
+ docsId("error"),
+ ]);
+ });
+
+ it("shows explain + docs only when no investigate or watch applies", () => {
+ // A deployment page has no failure to dig into and nothing to watch.
+ const prompts = resolveSuggestedPrompts(demoPageContexts.deployment, { now: NOW });
+
+ expect(ids(prompts)).toEqual(ids(pageDefaultPrompts(demoPageContexts.deployment.page)));
+ expect(prompts).toHaveLength(2);
+ expect(prompts.every((p) => p.source === "default")).toBe(true);
+ });
+
+ it("falls back to the generic explain + docs pair for an unclassified page", () => {
+ const prompts = resolveSuggestedPrompts(demoPageContexts.other, { now: NOW });
+
+ expect(ids(prompts)).toEqual(ids(GENERIC_PROMPTS));
+ expect(prompts.every((p) => p.source === "default")).toBe(true);
+ });
+
+ it("always puts the docs chip last, on every page kind", () => {
+ for (const [key, context] of Object.entries(demoPageContexts)) {
+ const prompts = resolveSuggestedPrompts(context, { promoted, now: NOW });
+ const last = prompts.at(-1);
+
+ expect(last?.id, key).toBe(docsId(key as keyof typeof demoPageContexts));
+ expect(
+ prompts.filter((p) => p.id === last?.id),
+ key
+ ).toHaveLength(1);
+ }
+ });
+
+ it("orders promoted, then investigate, then watch, then explain", () => {
+ // Queue page: saturation fills watch, the fresh failure fills investigate.
+ const context = {
+ ...demoPageContexts.queue,
+ signals: [...demoPageContexts.queue.signals, demoFreshFailureSignal],
+ };
+
+ const prompts = resolveSuggestedPrompts(context, { promoted, now: NOW });
+
+ expect(ids(prompts)).toEqual([
+ promoted.id,
+ "sp:fresh-failure",
+ // waiting_run beats concurrency_saturation for the watch slot.
+ "sp:waiting-run",
+ "sp:queue-state",
+ docsId("queue"),
+ ]);
+ });
+
+ it("gives a signal chip the slot ahead of the page-kind default", () => {
+ // The failed-run page can fill investigate itself; the fresh failure wins.
+ const prompts = resolveSuggestedPrompts(demoPageContexts.failedRun, { now: NOW });
+
+ expect(prompts[0]?.id).toBe("sp:fresh-failure");
+ expect(prompts[0]?.source).toBe("contextual");
+ expect(ids(prompts)).not.toContain("sp:run-investigate");
+ });
+
+ it("never returns more than the cap", () => {
+ for (const context of Object.values(demoPageContexts)) {
+ const prompts = resolveSuggestedPrompts(context, { promoted, now: NOW });
+ expect(prompts.length).toBeLessThanOrEqual(SUGGESTED_PROMPT_CAP);
+ expect(prompts.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("promotes the next candidate for a slot when its chip is dismissed", () => {
+ const full = resolveSuggestedPrompts(demoPageContexts.failedRun, { now: NOW });
+ const dismissed = resolveSuggestedPrompts(demoPageContexts.failedRun, {
+ now: NOW,
+ dismissedIds: ["sp:fresh-failure"],
+ });
+
+ expect(ids(full)[0]).toBe("sp:fresh-failure");
+ // The run page's own investigate default takes the vacated slot.
+ expect(ids(dismissed)[0]).toBe("sp:run-investigate");
+ expect(dismissed).toHaveLength(full.length);
+ });
+
+ it("collapses a slot with nothing left to offer", () => {
+ const full = resolveSuggestedPrompts(demoPageContexts.error, { now: NOW });
+ const dismissed = resolveSuggestedPrompts(demoPageContexts.error, {
+ now: NOW,
+ dismissedIds: ["sp:error-watch-recurrence"],
+ });
+
+ expect(ids(dismissed)).not.toContain("sp:error-watch-recurrence");
+ expect(dismissed).toHaveLength(full.length - 1);
+ // Docs is still last.
+ expect(dismissed.at(-1)?.id).toBe(docsId("error"));
+ });
+
+ it("can have the promoted chip dismissed like any other", () => {
+ const prompts = resolveSuggestedPrompts(demoPageContexts.runs, {
+ promoted,
+ now: NOW,
+ dismissedIds: [promoted.id],
+ });
+
+ expect(ids(prompts)).not.toContain(promoted.id);
+ });
+
+ it("de-duplicates a chip that is both a default and the promoted slot", () => {
+ const explain = pageSlotPrompts(demoPageContexts.other.page).explain;
+ const prompts = resolveSuggestedPrompts(demoPageContexts.other, {
+ promoted: { ...explain, source: "default" },
+ now: NOW,
+ });
+
+ expect(ids(prompts).filter((id) => id === explain.id)).toHaveLength(1);
+ expect(prompts[0]?.source).toBe("promoted");
+ });
+
+ it("sends the full prompt text, not the short label", () => {
+ const [failure] = resolveSuggestedPrompts(demoPageContexts.failedRun, { now: NOW });
+
+ expect(failure?.label).toBe("Why did this run fail?");
+ expect(failure?.prompt).toContain(
+ demoPageContexts.failedRun.page.kind === "run" ? demoPageContexts.failedRun.page.runId : ""
+ );
+ expect(failure?.prompt).toContain("12m ago");
+ });
+
+ it("words the waiting-run and slow-run chips for their slots", () => {
+ const waiting = resolveSuggestedPrompts(demoPageContexts.waitingRun, { now: NOW });
+ const waitingChip = waiting.find((p) => p.id === "sp:waiting-run");
+ expect(waitingChip?.label).toBe("Tell me when this run starts");
+ expect(waitingChip?.prompt).toContain("queue");
+
+ const slow = resolveSuggestedPrompts(demoPageContexts.slowRun, { now: NOW });
+ expect(slow[0]?.label).toBe("~7.8x slower than usual");
+ });
+
+ it("skips a slow_run signal with no usable baseline", () => {
+ const prompts = resolveSuggestedPrompts(
+ {
+ page: { kind: "other", path: "/whatever" },
+ signals: [{ kind: "slow_run", runId: "run_x", durationMs: 5000, baselineP95Ms: 0 }],
+ },
+ { now: NOW }
+ );
+
+ expect(ids(prompts)).toEqual(ids(GENERIC_PROMPTS));
+ });
+
+ it("exposes the contract's resolver shape with options bound", () => {
+ const resolver = makeSuggestedPromptResolver({ promoted, now: NOW });
+ expect(resolver(demoPageContexts.failedRun)[0]?.id).toBe(promoted.id);
+ });
+});
+
+describe("pageSlotPrompts", () => {
+ it("gives every page kind an explain and a docs chip", () => {
+ for (const context of Object.values(demoPageContexts)) {
+ const slots = pageSlotPrompts(context.page);
+ expect(slots.explain.label, context.page.kind).toBeTruthy();
+ expect(slots.docs.label, context.page.kind).toBeTruthy();
+ }
+ });
+
+ it("gives every page kind a non-empty, uniquely-identified default set", () => {
+ for (const context of Object.values(demoPageContexts)) {
+ const prompts = pageDefaultPrompts(context.page);
+ expect(prompts.length, context.page.kind).toBeGreaterThan(0);
+ expect(new Set(ids(prompts)).size, context.page.kind).toBe(prompts.length);
+ // Docs is always the tail of the page's own set too.
+ expect(prompts.at(-1)?.id, context.page.kind).toBe(pageSlotPrompts(context.page).docs.id);
+ }
+ });
+
+ it("mentions the page's own subject where the page has one", () => {
+ const runPage = demoPageContexts.failedRun.page;
+ if (runPage.kind !== "run") throw new Error("fixture changed");
+ expect(pageDefaultPrompts(runPage).some((p) => p.prompt.includes(runPage.runId))).toBe(true);
+ expect(pageDefaultPrompts(runPage).some((p) => p.prompt.includes(runPage.taskId))).toBe(true);
+
+ const queuePage = demoPageContexts.queue.page;
+ if (queuePage.kind !== "queue") throw new Error("fixture changed");
+ expect(pageDefaultPrompts(queuePage).some((p) => p.prompt.includes(queuePage.name))).toBe(true);
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.ts
new file mode 100644
index 00000000000..e69b0ffdf5e
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.ts
@@ -0,0 +1,79 @@
+/**
+ * Turning a page context into the chips the panel shows.
+ *
+ * The row is a fixed set of slots, in this order:
+ *
+ * 1. `promoted` — the product-chosen chip, when one is configured.
+ * 2. `investigate` — when the page or its signals make one relevant.
+ * 3. `watch` — when there's something worth watching (a waiting run, a saturated
+ * queue, a recurring error).
+ * 4. `explain` — the evergreen explain/find/show question. Always present.
+ * 5. `docs` — a doc-flavored question. Always present, always last.
+ *
+ * Slots nothing can fill collapse, so the row shows what exists rather than
+ * padding itself. Each slot has an ordered candidate list (signal chips first,
+ * then the page-kind default), so dismissing a chip promotes the next candidate
+ * for that slot instead of leaving a hole.
+ */
+import {
+ SUGGESTED_PROMPT_CAP,
+ type AgentPageContext,
+ type SuggestedPrompt,
+ type SuggestedPromptResolver,
+} from "@internal/dashboard-agent-contracts";
+import { contextualPromptsBySlot, pageSlotPrompts, PROMPT_SLOTS } from "./registry";
+
+export type ResolveSuggestedPromptsOptions = {
+ /** The product-controlled slot, taken as-is and forced to `promoted`. */
+ promoted?: SuggestedPrompt;
+ /** Chip ids this user has dismissed. */
+ dismissedIds?: string[];
+ /** Injectable clock, so "failed 3m ago" is testable. */
+ now?: number;
+};
+
+export function resolveSuggestedPrompts(
+ context: AgentPageContext,
+ opts: ResolveSuggestedPromptsOptions = {}
+): SuggestedPrompt[] {
+ const now = opts.now ?? Date.now();
+ const dismissed = new Set(opts.dismissedIds ?? []);
+
+ const contextual = contextualPromptsBySlot(context, now);
+ const pageSlots = pageSlotPrompts(context.page);
+
+ const resolved: SuggestedPrompt[] = [];
+ const seen = new Set();
+
+ const take = (candidates: (SuggestedPrompt | undefined)[]): void => {
+ for (const candidate of candidates) {
+ if (!candidate || dismissed.has(candidate.id) || seen.has(candidate.id)) continue;
+ seen.add(candidate.id);
+ resolved.push(candidate);
+ return;
+ }
+ };
+
+ // Whatever the flag says, it occupies the promoted slot — the caller doesn't
+ // have to remember to set `source`.
+ if (opts.promoted) {
+ take([{ ...opts.promoted, source: "promoted" }]);
+ }
+
+ for (const slot of PROMPT_SLOTS) {
+ take([...contextual[slot], pageSlots[slot]]);
+ }
+
+ return resolved.slice(0, SUGGESTED_PROMPT_CAP);
+}
+
+/**
+ * The contract's resolver shape, with the promoted slot and dismissals bound.
+ * Lets a caller that only has a page context (the prompts component) hold a
+ * plain `(context) => prompts` function.
+ */
+export function makeSuggestedPromptResolver(
+ opts: ResolveSuggestedPromptsOptions = {}
+): SuggestedPromptResolver {
+ return (context) => resolveSuggestedPrompts(context, opts);
+}
diff --git a/apps/webapp/app/components/dashboard-agent/tool-labels.test.ts b/apps/webapp/app/components/dashboard-agent/tool-labels.test.ts
new file mode 100644
index 00000000000..7ccf2778f63
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/tool-labels.test.ts
@@ -0,0 +1,26 @@
+import { dashboardAgentCodeToolSchemas } from "@internal/dashboard-agent/tool-schemas";
+import { describe, expect, it } from "vitest";
+import { toolPendingLabel } from "./tool-labels";
+
+describe("toolPendingLabel", () => {
+ it("names every tool the agent can call", () => {
+ // A tool with no phrase falls back to `Running `, which is the one
+ // thing the pill should never say for a tool we ship.
+ const unnamed = Object.keys(dashboardAgentCodeToolSchemas).filter((name) =>
+ toolPendingLabel(name).startsWith("Running ")
+ );
+ // `run_query` is the exception: "Running a query" is the phrase, not a fallback.
+ expect(unnamed).toEqual(["run_query"]);
+ });
+
+ it("falls back to the tool name for a tool it doesn't know", () => {
+ expect(toolPendingLabel("get_queue_health")).toBe("Running get_queue_health");
+ });
+
+ it("reads as a phrase, not an identifier", () => {
+ expect(toolPendingLabel("get_run")).toBe("Reading the run");
+ expect(toolPendingLabel("render_view")).toBe("Rendering a card");
+ // No trailing ellipsis — the pill adds it.
+ expect(toolPendingLabel("get_report")).not.toMatch(/…$/);
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/tool-labels.ts b/apps/webapp/app/components/dashboard-agent/tool-labels.ts
new file mode 100644
index 00000000000..9a8c319e227
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/tool-labels.ts
@@ -0,0 +1,48 @@
+/**
+ * What a tool call is called while it is still running.
+ *
+ * An in-flight tool call shows a pending pill, and the pill needs one short
+ * phrase saying what the agent is doing — not the tool's identifier. The phrases
+ * are written from the reader's side ("Reading the run", not "get_run"), present
+ * tense, no trailing ellipsis: the pill adds that.
+ *
+ * A tool that isn't in the map keeps the old wording, `Running `, so a new
+ * tool is readable before anyone gets round to naming it here.
+ */
+
+const TOOL_LABELS: Record = {
+ list_projects: "Listing projects",
+ list_environments: "Listing environments",
+ list_tasks: "Listing tasks",
+ list_runs: "Looking through runs",
+ get_run: "Reading the run",
+ get_run_trace: "Reading the run's trace",
+ list_errors: "Looking through errors",
+ get_error: "Reading the error",
+ get_query_schema: "Reading the data schema",
+ run_query: "Running a query",
+ ask_support: "Asking support",
+ render_view: "Rendering a card",
+ get_report: "Building the health report",
+ get_queue: "Reading the queue",
+ list_deploys: "Looking through deploys",
+ get_deploy: "Reading the deploy",
+ correlate_version: "Correlating versions",
+ search_docs: "Searching the docs",
+ get_current_page: "Reading the current page",
+ navigate_to: "Opening the page",
+ schedule_watch: "Setting up a watch",
+ list_alerts: "Listing alerts",
+ create_alert: "Creating an alert",
+ delete_alert: "Deleting an alert",
+ // Code mode.
+ get_repo_info: "Reading the repo",
+ list_files: "Listing files",
+ read_file: "Reading a file",
+ search_code: "Searching the code",
+};
+
+/** The pending pill's label for a tool, by its name (no `tool-` prefix). */
+export function toolPendingLabel(toolName: string): string {
+ return TOOL_LABELS[toolName] ?? `Running ${toolName}`;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/useTranscriptAutoScroll.ts b/apps/webapp/app/components/dashboard-agent/useTranscriptAutoScroll.ts
new file mode 100644
index 00000000000..3a73fbcea33
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/useTranscriptAutoScroll.ts
@@ -0,0 +1,95 @@
+import { useEffect, useLayoutEffect, useRef } from "react";
+
+/**
+ * How close to the bottom counts as "following along". Generous, because a turn
+ * streams in a line at a time and a few pixels of drift shouldn't stop the
+ * transcript from following.
+ */
+const NEAR_BOTTOM_PX = 120;
+
+type ScrollableMessage = { id: string; role?: string };
+
+/**
+ * Sticky-bottom scrolling for the chat transcript.
+ *
+ * Three rules, in order:
+ *
+ * 1. A message the user just sent always wins — the transcript jumps to the
+ * bottom however far up they had scrolled, whether the message came from the
+ * composer, a card's button or a prompt chip.
+ * 2. New streamed content follows only while they are already near the bottom,
+ * so reading back through a transcript is never yanked away.
+ * 3. Mounting lands at the bottom. The panel remounts the chat on every page
+ * navigation, and a restored transcript that opens at the top reads as an
+ * empty chat.
+ *
+ * The shared `useAutoScrollToBottom` can't do (1) or (3): it seeds its
+ * stickiness from the scroll position *after* the first paint, so a restored
+ * transcript starts at scrollTop 0, is judged "not at the bottom", and never
+ * scrolls again.
+ *
+ * @returns the ref for the content column *inside* the scroller.
+ */
+export function useTranscriptAutoScroll(
+ messages: ReadonlyArray,
+ activity: unknown
+) {
+ const contentRef = useRef(null);
+ const containerRef = useRef(null);
+ const followRef = useRef(true);
+ // The last user message scrolled for, so rule 1 fires once per send.
+ const jumpedForRef = useRef(undefined);
+
+ // Find the scroller, land at the bottom, and track whether we're following.
+ useLayoutEffect(() => {
+ let element: HTMLElement | null = contentRef.current?.parentElement ?? null;
+ while (element) {
+ const overflowY = getComputedStyle(element).overflowY;
+ if (overflowY === "auto" || overflowY === "scroll") break;
+ element = element.parentElement;
+ }
+ if (!element) return;
+ const container = element;
+ containerRef.current = container;
+ container.scrollTop = container.scrollHeight;
+
+ const onScroll = () => {
+ const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
+ followRef.current = distance <= NEAR_BOTTOM_PX;
+ };
+ container.addEventListener("scroll", onScroll, { passive: true });
+ return () => {
+ container.removeEventListener("scroll", onScroll);
+ containerRef.current = null;
+ };
+ }, []);
+
+ useLayoutEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const last = messages[messages.length - 1];
+ const sent = last?.role === "user" && jumpedForRef.current !== last.id;
+ if (sent) jumpedForRef.current = last!.id;
+ if (!sent && !followRef.current) return;
+
+ followRef.current = true;
+ container.scrollTop = container.scrollHeight;
+ }, [messages, activity]);
+
+ // Content also grows without a message change — markdown finishing its lazy
+ // load, a card laying out. Follow that too, but only while following.
+ useEffect(() => {
+ const container = containerRef.current;
+ const content = contentRef.current;
+ if (!container || !content || typeof ResizeObserver === "undefined") return;
+
+ const observer = new ResizeObserver(() => {
+ if (followRef.current) container.scrollTop = container.scrollHeight;
+ });
+ observer.observe(content);
+ return () => observer.disconnect();
+ }, []);
+
+ return contentRef;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/view-blocks.test.ts b/apps/webapp/app/components/dashboard-agent/view-blocks.test.ts
new file mode 100644
index 00000000000..f3391d922de
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/view-blocks.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from "vitest";
+import { blockIdentity, blockKey, latestRevisionBlocks } from "./view-blocks";
+
+const enveloped = (id: string, revision: number, type = "diagnosis") => ({ type, id, revision });
+
+describe("latestRevisionBlocks", () => {
+ it("keeps every block when none carry an envelope", () => {
+ const blocks = [{ type: "diagnosis" }, { type: "chart" }, { type: "diagnosis" }];
+ expect(latestRevisionBlocks(blocks)).toEqual(blocks);
+ });
+
+ it("collapses revisions of the same (type, id) to the highest revision", () => {
+ const blocks = [enveloped("d1", 1), enveloped("d1", 3), enveloped("d1", 2)];
+ expect(latestRevisionBlocks(blocks)).toEqual([enveloped("d1", 3)]);
+ });
+
+ it("keeps the last block on a revision tie", () => {
+ const first = { type: "diagnosis", id: "d1", revision: 2, summary: "old" };
+ const second = { type: "diagnosis", id: "d1", revision: 2, summary: "new" };
+ expect(latestRevisionBlocks([first, second])).toEqual([second]);
+ });
+
+ it("treats a missing revision as 0", () => {
+ const blocks = [{ type: "diagnosis", id: "d1" }, enveloped("d1", 1)];
+ expect(latestRevisionBlocks(blocks)).toEqual([enveloped("d1", 1)]);
+ });
+
+ it("does not group across types or ids", () => {
+ const blocks = [enveloped("d1", 1), enveloped("d1", 1, "chart"), enveloped("d2", 1)];
+ expect(latestRevisionBlocks(blocks)).toEqual(blocks);
+ });
+
+ it("keeps envelope-less blocks alongside collapsed ones, in order", () => {
+ const legacy = { type: "chart" };
+ const blocks = [enveloped("d1", 1), legacy, enveloped("d1", 2)];
+ expect(latestRevisionBlocks(blocks)).toEqual([legacy, enveloped("d1", 2)]);
+ });
+
+ // The investigation block is the one *progressive* block: the executor commits
+ // a revision per render and stamps it on, so a chat that renders the same
+ // investigation three times must still show one card — the current one.
+ it("collapses an investigation's revisions to the current one", () => {
+ const revision = (n: number, outcome: string) => ({
+ type: "investigation",
+ id: "inv_abc123",
+ revision: n,
+ version: 1,
+ investigation: { outcome },
+ });
+ const blocks = [
+ revision(0, "in_progress"),
+ revision(1, "in_progress"),
+ revision(2, "concluded"),
+ ];
+ expect(latestRevisionBlocks(blocks)).toEqual([revision(2, "concluded")]);
+ });
+
+ it("keeps two different investigations apart", () => {
+ const blocks = [enveloped("inv_1", 1, "investigation"), enveloped("inv_2", 0, "investigation")];
+ expect(latestRevisionBlocks(blocks)).toEqual(blocks);
+ });
+
+ it("tolerates a non-array", () => {
+ expect(latestRevisionBlocks(undefined as unknown as unknown[])).toEqual([]);
+ });
+});
+
+describe("blockIdentity / blockKey", () => {
+ it("identifies enveloped blocks by type and id", () => {
+ expect(blockIdentity(enveloped("d1", 1))).toBe("diagnosis::d1");
+ });
+
+ it("has no identity without a usable id", () => {
+ expect(blockIdentity({ type: "diagnosis" })).toBeUndefined();
+ expect(blockIdentity({ type: "diagnosis", id: "" })).toBeUndefined();
+ expect(blockIdentity({ id: "d1" })).toBeUndefined();
+ expect(blockIdentity(null)).toBeUndefined();
+ });
+
+ it("falls back to the index as the key", () => {
+ expect(blockKey({ type: "diagnosis" }, 2)).toBe("index:2");
+ expect(blockKey(enveloped("d1", 1), 2)).toBe("diagnosis::d1");
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/view-blocks.ts b/apps/webapp/app/components/dashboard-agent/view-blocks.ts
new file mode 100644
index 00000000000..8bce943e176
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/view-blocks.ts
@@ -0,0 +1,70 @@
+// Envelope handling for the agent's view blocks. The envelope is defined by
+// `@internal/dashboard-agent-contracts` (`blockEnvelopeSchema`): `id` is stable
+// identity within a conversation, `revision` increases when the agent re-emits
+// the same block with better information. Blocks replayed from a pre-envelope
+// transcript have neither, so everything here degrades to "render all, in
+// order". Reads defensively rather than trusting the parsed type, since blocks
+// arrive as tool output.
+//
+// Kept as pure functions, separate from the renderer, so the latest-wins
+// semantics can be tested without a DOM.
+
+type MaybeEnveloped = {
+ type?: unknown;
+ id?: unknown;
+ revision?: unknown;
+};
+
+/**
+ * The identity two blocks must share to be revisions of each other: type + id.
+ * Undefined when the block has no envelope — such blocks are never grouped.
+ */
+export function blockIdentity(block: unknown): string | undefined {
+ const { type, id } = (block ?? {}) as MaybeEnveloped;
+ if (typeof id !== "string" || id.length === 0) return undefined;
+ if (typeof type !== "string") return undefined;
+ return `${type}::${id}`;
+}
+
+function blockRevision(block: unknown): number {
+ const { revision } = (block ?? {}) as MaybeEnveloped;
+ return typeof revision === "number" && Number.isFinite(revision) ? revision : 0;
+}
+
+/**
+ * The React key for a block: its identity when it has an envelope, else the
+ * array index (legacy blocks have nothing stable to key on).
+ */
+export function blockKey(block: unknown, index: number): string {
+ return blockIdentity(block) ?? `index:${index}`;
+}
+
+/**
+ * Latest-wins within one blocks array: when several blocks share (type, id),
+ * keep only the highest `revision` (ties keep the last one, i.e. the newest
+ * emission) at that winner's position. Blocks without an envelope are all kept,
+ * in order. Cross-message grouping is a later milestone.
+ */
+export function latestRevisionBlocks(blocks: readonly T[]): T[] {
+ if (!Array.isArray(blocks)) return [];
+
+ const winnerIndexByIdentity = new Map();
+ blocks.forEach((block, index) => {
+ const identity = blockIdentity(block);
+ if (identity === undefined) return;
+ const currentWinner = winnerIndexByIdentity.get(identity);
+ if (
+ currentWinner === undefined ||
+ blockRevision(blocks[currentWinner]) <= blockRevision(block)
+ ) {
+ winnerIndexByIdentity.set(identity, index);
+ }
+ });
+
+ if (winnerIndexByIdentity.size === 0) return [...blocks];
+
+ return blocks.filter((block, index) => {
+ const identity = blockIdentity(block);
+ return identity === undefined || winnerIndexByIdentity.get(identity) === index;
+ });
+}
diff --git a/apps/webapp/app/components/dashboard-agent/view-catalog.tsx b/apps/webapp/app/components/dashboard-agent/view-catalog.tsx
index ab9fdbb1380..36cde1fadf3 100644
--- a/apps/webapp/app/components/dashboard-agent/view-catalog.tsx
+++ b/apps/webapp/app/components/dashboard-agent/view-catalog.tsx
@@ -1,6 +1,9 @@
-import type { ViewBlock } from "@internal/dashboard-agent";
+import type { AgentIntent, ViewBlock } from "@internal/dashboard-agent-contracts";
import { AgentChart } from "./AgentChart";
+import { InvestigationCard } from "./InvestigationCard";
+import { ReportView, type ResolvedUri } from "./ReportView";
import { RunDiagnosisCard } from "./RunDiagnosisCard";
+import { blockKey, latestRevisionBlocks } from "./view-blocks";
// The render registry for the dashboard agent's view catalog — our small
// "generative UI" layer. The agent emits a `render_view` tool call whose output
@@ -10,16 +13,60 @@ import { RunDiagnosisCard } from "./RunDiagnosisCard";
// render arbitrary content — same guarantee a generative-UI framework gives,
// without the dependency. Add a block by adding a `case` here and a union
// member in the package's `viewBlockSchema`.
-export function ViewBlocks({ blocks }: { blocks: ViewBlock[] }) {
+//
+// Blocks may carry an envelope (`id` + `revision`): those are keyed by identity
+// and collapsed latest-wins, so a re-emitted block replaces its earlier
+// revision instead of stacking a second card. Blocks without one render as
+// before, keyed by index. See `view-blocks.ts`.
+//
+// Two ways a card reaches the user, both ending here: `render_view` blocks the
+// model composed, and blocks the host synthesises from a tool result (a `report`
+// is built from the completed `get_report` call — see `report-block-adapter.ts`).
+export function ViewBlocks({
+ blocks,
+ /**
+ * Where a card's actions go. Cards never navigate or ask on their own — they
+ * emit an intent and the host honours it (or doesn't). Optional: without it,
+ * intent-only actions render as plain text rather than dead buttons.
+ */
+ onIntent,
+ /**
+ * Host resolver for `trigger://` URIs cited by a card. Only the host knows the
+ * environment the URI should resolve against; see `resolveTriggerUri.server.ts`
+ * for the server-side mapping.
+ */
+ resolveUri,
+}: {
+ blocks: ViewBlock[];
+ onIntent?: (intent: AgentIntent) => void;
+ resolveUri?: (uri: string) => ResolvedUri | null;
+}) {
if (!Array.isArray(blocks)) return null;
return (
- {blocks.map((block, i) => {
+ {latestRevisionBlocks(blocks).map((block) => {
+ // Index-keyed (envelope-less) blocks key off their position in the
+ // original array so collapsing a revision above them can't shift keys.
+ const key = blockKey(block, blocks.indexOf(block));
switch (block.type) {
case "diagnosis":
- return ;
+ return ;
case "chart":
- return ;
+ return ;
+ // The one progressive block: revisions share the investigationId, so
+ // latest-wins above keeps a single live card.
+ case "investigation":
+ return ;
+ case "report":
+ return (
+
+ );
default:
return null;
}
diff --git a/apps/webapp/app/components/dashboard-agent/watch-chips.test.ts b/apps/webapp/app/components/dashboard-agent/watch-chips.test.ts
new file mode 100644
index 00000000000..4734d5966db
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/watch-chips.test.ts
@@ -0,0 +1,107 @@
+import { watchIdentity, type WatchSpec } from "@internal/dashboard-agent-contracts";
+import { describe, expect, it } from "vitest";
+import { immediateWatchMessage, watchChipLabel, watchChipTooltip } from "./watch-chips";
+
+const chip = (spec: WatchSpec) => ({
+ kind: spec.kind,
+ identity: watchIdentity(spec),
+ note: spec.note,
+});
+
+describe("watchChipLabel", () => {
+ it("labels a run watch with its run id", () => {
+ expect(
+ watchChipLabel(
+ chip({
+ kind: "run_finished",
+ runId: "run_abc123",
+ note: "Tell me when the retry finishes.",
+ maxHours: 2,
+ checkEveryMinutes: 1,
+ })
+ )
+ ).toBe("run_abc123");
+ });
+
+ it("labels a backlog watch with the queue name", () => {
+ expect(
+ watchChipLabel(
+ chip({
+ kind: "backlog_drain",
+ queue: "task/send-email",
+ note: "Tell me when the backlog clears.",
+ maxHours: 6,
+ checkEveryMinutes: 5,
+ })
+ )
+ ).toBe("task/send-email");
+ });
+
+ it("shortens an error fingerprint", () => {
+ expect(
+ watchChipLabel(
+ chip({
+ kind: "error_recurrence",
+ fingerprint: "0123456789abcdef0123456789abcdef",
+ note: "Tell me if the rate-limit error comes back.",
+ maxHours: 12,
+ checkEveryMinutes: 15,
+ })
+ )
+ ).toBe("01234567");
+ });
+
+ it("labels a health watch by its kind, not its report", () => {
+ expect(
+ watchChipLabel(
+ chip({
+ kind: "health_recovery",
+ report: "health",
+ fromSeverity: "crit",
+ note: "prod health back to normal",
+ maxHours: 4,
+ checkEveryMinutes: 15,
+ })
+ )
+ ).toBe("health");
+ });
+
+ it("falls back to the first words of the note when the identity is unreadable", () => {
+ expect(
+ watchChipLabel({ kind: "run_start", identity: "nonsense", note: "Tell me when it starts" })
+ ).toBe("Tell me when");
+ });
+
+ it("falls back to the kind when there is no note either", () => {
+ expect(watchChipLabel({ kind: "run_start", identity: "", note: " " })).toBe("run_start");
+ });
+});
+
+describe("watchChipTooltip", () => {
+ it("carries the note, the cadence and the state", () => {
+ expect(
+ watchChipTooltip({
+ note: "Tell me when prod recovers.",
+ checkEveryMinutes: 15,
+ status: "active",
+ })
+ ).toBe("Tell me when prod recovers. · every 15 min · watching");
+ });
+
+ it("drops an empty note rather than leaving a dangling separator", () => {
+ expect(watchChipTooltip({ note: "", checkEveryMinutes: 5, status: "fired" })).toBe(
+ "every 5 min · fired"
+ );
+ });
+});
+
+describe("immediateWatchMessage", () => {
+ it("says the condition already resolved", () => {
+ expect(immediateWatchMessage("satisfied")).toMatch(/already happened/);
+ expect(immediateWatchMessage("terminal_unsatisfied")).toMatch(/can't happen any more/);
+ });
+
+ it("never falls through to nothing", () => {
+ expect(immediateWatchMessage("something-new")).toBeTruthy();
+ });
+});
diff --git a/apps/webapp/app/components/dashboard-agent/watch-chips.ts b/apps/webapp/app/components/dashboard-agent/watch-chips.ts
new file mode 100644
index 00000000000..dee71502f48
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/watch-chips.ts
@@ -0,0 +1,81 @@
+/**
+ * The pure text of the watch UI: what a chip is labelled, what its tooltip says,
+ * and how an immediate outcome is worded.
+ *
+ * A chip has one line of room in a 380px panel, so the label names the *thing*
+ * being watched (the run, the queue, the error) and the icon carries the state.
+ * The label comes from the watch `identity` — the same dedup key the store uses —
+ * so two chips can never disagree with the store about what they watch.
+ */
+import type { WatchStatus } from "@internal/dashboard-agent-contracts";
+
+export const WATCH_STATUS_LABEL: Record = {
+ active: "watching",
+ fired: "fired",
+ expired: "expired",
+ cancelled: "cancelled",
+};
+
+/** Fingerprints are hashes — a chip shows just enough of one to tell them apart. */
+const FINGERPRINT_CHARS = 8;
+
+/**
+ * The chip label for a watch. `identity` is `{kind}:{value}`, so the value is the
+ * thing being watched; a health watch has no per-instance value, so its kind is
+ * the label. Falls back to the note (then the kind) if the identity is unreadable.
+ */
+export function watchChipLabel(watch: { kind: string; identity: string; note: string }): string {
+ const value = watch.identity.startsWith(`${watch.kind}:`)
+ ? watch.identity.slice(watch.kind.length + 1)
+ : "";
+
+ switch (watch.kind) {
+ case "run_start":
+ case "run_finished":
+ case "backlog_drain":
+ return value || fallbackLabel(watch);
+ case "error_recurrence":
+ return value ? value.slice(0, FINGERPRINT_CHARS) : fallbackLabel(watch);
+ case "health_recovery":
+ return "health";
+ default:
+ return value || fallbackLabel(watch);
+ }
+}
+
+/** Last resort: the first few words of the note, else the kind as written. */
+function fallbackLabel(watch: { kind: string; note: string }): string {
+ const words = watch.note.trim().split(/\s+/).filter(Boolean).slice(0, 3).join(" ");
+ return words || watch.kind;
+}
+
+/** Everything that didn't fit on the chip: why it exists, and its cadence. */
+export function watchChipTooltip(watch: {
+ note: string;
+ checkEveryMinutes: number;
+ status: WatchStatus;
+}): string {
+ const note = watch.note.trim();
+ const cadence = `every ${watch.checkEveryMinutes} min`;
+ return [note, cadence, WATCH_STATUS_LABEL[watch.status]].filter(Boolean).join(" · ");
+}
+
+/**
+ * A watch whose condition resolved the moment it was created never becomes a
+ * chip — there is nothing left to watch — so the answer is said once, in a toast.
+ *
+ * `pending` and `unavailable` are not immediate outcomes (the watch stays active),
+ * but they're handled so a future result can never fall through to nothing.
+ */
+export function immediateWatchMessage(result: string): string {
+ switch (result) {
+ case "satisfied":
+ return "That already happened, so there's nothing left to watch.";
+ case "terminal_unsatisfied":
+ return "That can't happen any more, so there's nothing to watch.";
+ case "unavailable":
+ return "We couldn't check that just now. Watching anyway.";
+ default:
+ return "Watching.";
+ }
+}
diff --git a/apps/webapp/app/components/layout/MetricsLayout.tsx b/apps/webapp/app/components/layout/MetricsLayout.tsx
new file mode 100644
index 00000000000..12d0fcd743c
--- /dev/null
+++ b/apps/webapp/app/components/layout/MetricsLayout.tsx
@@ -0,0 +1,368 @@
+/**
+ * MetricsLayout — a compound layout for metric / dashboard pages.
+ *
+ * Slots bake all the chrome; there is no `className` on any slot, so pages can't drift apart on
+ * spacing. Need a variant? Add a closed prop (`kind`, `inset`), don't reopen `className`.
+ *
+ * Slots, top to bottom:
+ * - `Filters` — pinned 40px bar under the NavBar. Left/right clusters are child divs.
+ * - `Grid` — tiles; columns derived from tile count unless `columns` is set. `kind="charts"`
+ * bakes the fixed chart-row height.
+ * - `Content` — table / tabs below the tiles. Full-bleed by default; `inset` for a padded column.
+ *
+ * Optional:
+ * - `Sidebar` — a persistent right-hand panel; fixed `width` or `resizable`. Present ⇒ Root
+ * switches to `[main | sidebar]`; absent ⇒ single column.
+ * - `scroll` on Root — `"page"` (default): the whole page scrolls as one. `"regions"`: Root owns
+ * no scroll, the page composes its own scrolling areas.
+ *
+ * Purely presentational. Lives inside a `PageContainer` after the `NavBar`.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
…search + TimeFilter…
+ *
+ *
+ * …stat tiles…
+ * …chart tiles…
+ * …table…
+ *
+ * ```
+ */
+import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
+import { PageBody } from "~/components/layout/AppLayout";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+ type ResizableSnapshot,
+} from "~/components/primitives/Resizable";
+import { cn } from "~/utils/cn";
+
+type ColumnCount = 1 | 2 | 3 | 4 | 5 | 6;
+
+/**
+ * A responsive column spec. Each key is a breakpoint (mobile-first `base`, then `sm`/`md`/`lg`);
+ * the value is the number of grid columns from that breakpoint up. Pass this to `Grid` when the
+ * tile count shouldn't drive the layout (e.g. a chart grid that is always two-up).
+ */
+export type GridColumns = {
+ base?: ColumnCount;
+ sm?: ColumnCount;
+ md?: ColumnCount;
+ lg?: ColumnCount;
+};
+
+// Static class maps so Tailwind's scanner sees every column class as a literal string.
+const BASE_COLS: Record = {
+ 1: "grid-cols-1",
+ 2: "grid-cols-2",
+ 3: "grid-cols-3",
+ 4: "grid-cols-4",
+ 5: "grid-cols-5",
+ 6: "grid-cols-6",
+};
+const SM_COLS: Record = {
+ 1: "sm:grid-cols-1",
+ 2: "sm:grid-cols-2",
+ 3: "sm:grid-cols-3",
+ 4: "sm:grid-cols-4",
+ 5: "sm:grid-cols-5",
+ 6: "sm:grid-cols-6",
+};
+const MD_COLS: Record = {
+ 1: "md:grid-cols-1",
+ 2: "md:grid-cols-2",
+ 3: "md:grid-cols-3",
+ 4: "md:grid-cols-4",
+ 5: "md:grid-cols-5",
+ 6: "md:grid-cols-6",
+};
+const LG_COLS: Record = {
+ 1: "lg:grid-cols-1",
+ 2: "lg:grid-cols-2",
+ 3: "lg:grid-cols-3",
+ 4: "lg:grid-cols-4",
+ 5: "lg:grid-cols-5",
+ 6: "lg:grid-cols-6",
+};
+
+// When `columns` is omitted the grid figures itself out from the tile count. The breakpoints are
+// chosen so the common metric layouts fall out for free: a trio of stat blocks goes one-up then
+// three-up, a quartet of stat/chart tiles goes two-up then four-up, and anything larger settles
+// into a comfortable two-up.
+function columnsForCount(count: number): GridColumns {
+ switch (count) {
+ case 1:
+ return { base: 1 };
+ case 2:
+ return { base: 1, sm: 2 };
+ case 3:
+ return { base: 1, sm: 3 };
+ case 4:
+ return { base: 2, lg: 4 };
+ default:
+ return { base: 1, sm: 2 };
+ }
+}
+
+/**
+ * Who owns the vertical scroll.
+ * - `"page"` (default): Root owns one `overflow-y-auto` and the column rhythm — the whole page
+ * scrolls as one.
+ * - `"regions"`: Root only bounds the height (a bare `flex` column, no scroll, no rhythm); the
+ * page composes its own scrolling areas inside the slots.
+ */
+export type MetricsScroll = "page" | "regions";
+
+/** A length the resizable panels accept: pixels or percent (the panel library's `Unit`). */
+type PanelLength = `${number}px` | `${number}%`;
+
+type MetricsLayoutSidebarProps = {
+ children: ReactNode;
+ /**
+ * Fixed sidebar width for the non-resizable default (any CSS length, e.g. `"380px"`, `"22rem"`).
+ * Ignored when `resizable` is set. Defaults to `"380px"`.
+ */
+ width?: string;
+ /**
+ * Makes the split draggable. To persist it, pass an `autosaveId` (written to a cookie) plus the
+ * `snapshot` read back in the loader via `getResizableSnapshot(request, autosaveId)`.
+ */
+ resizable?: boolean;
+ /** Resizable only: min width of the sidebar panel. Defaults to `"280px"`. */
+ min?: PanelLength;
+ /** Resizable only: initial width of the sidebar panel. Defaults to `"380px"`. */
+ defaultSize?: PanelLength;
+ /** Resizable only: max width of the sidebar panel. */
+ max?: PanelLength;
+ /** Resizable only: min width of the main panel. Defaults to `"300px"`. */
+ mainMin?: PanelLength;
+ /** Resizable only: cookie name the split is persisted under (also the panel-group id). */
+ autosaveId?: string;
+ /** Resizable only: server-loaded split snapshot to hydrate from (see `getResizableSnapshot`). */
+ snapshot?: ResizableSnapshot;
+};
+
+/**
+ * Marker slot for the persistent side panel. Rendered/positioned entirely by `Root` (this
+ * component is never mounted directly) — Root reads its props to build the `[main | sidebar]`
+ * layout and drops the children into the panel. The panel itself owns its chrome (border, scroll);
+ * pass those as part of the children, not as a class on the slot.
+ */
+function MetricsLayoutSidebar(_props: MetricsLayoutSidebarProps) {
+ return null;
+}
+
+function isSidebarElement(child: ReactNode): child is ReactElement {
+ return isValidElement(child) && child.type === MetricsLayoutSidebar;
+}
+
+function isFiltersElement(child: ReactNode): child is ReactElement {
+ return isValidElement(child) && child.type === MetricsLayoutFilters;
+}
+
+// The main (left) column. Filters is hoisted out of the scroll container so it stays pinned while
+// the rest scrolls. `"page"` scrolls as one with the baked column rhythm; `"regions"` stays bare so
+// the page owns its own scrolling.
+function MetricsLayoutMain({ children, scroll }: { children: ReactNode; scroll: MetricsScroll }) {
+ const arr = Children.toArray(children);
+ const filters = arr.find(isFiltersElement);
+ const rest = filters ? arr.filter((child) => !isFiltersElement(child)) : children;
+
+ return (
+
+ {filters}
+
+ {rest}
+
+
+ );
+}
+
+function MetricsLayoutRoot({
+ children,
+ scroll = "page",
+}: {
+ children: ReactNode;
+ /** Who owns the vertical scroll — see {@link MetricsScroll}. Defaults to `"page"`. */
+ scroll?: MetricsScroll;
+}) {
+ // A single optional Sidebar slot flips Root into a horizontal `[main | sidebar]` layout. When it
+ // is absent the output is the plain single-column markup.
+ const sidebar = Children.toArray(children).find(isSidebarElement);
+ const mainChildren = sidebar
+ ? Children.toArray(children).filter((child) => !isSidebarElement(child))
+ : children;
+
+ const main = {mainChildren};
+
+ if (!sidebar) {
+ return (
+
+ {/* The whole page scrolls as one: filters (pinned) aside, the tiles and content share a
+ single vertical scroll context. */}
+ {main}
+
+ );
+ }
+
+ const {
+ children: sidebarChildren,
+ width = "380px",
+ resizable,
+ min = "280px",
+ defaultSize = "380px",
+ max,
+ mainMin = "300px",
+ autosaveId,
+ snapshot,
+ } = sidebar.props;
+
+ if (resizable) {
+ // Draggable split. `autosaveId`/`snapshot` wire up cookie persistence exactly as the run and
+ // agent pages do (client writes the cookie, the loader hydrates via getResizableSnapshot).
+ return (
+
+
+
+ {main}
+
+
+
+ {sidebarChildren}
+
+
+
+ );
+ }
+
+ // Fixed-width sidebar.
+ return (
+
+
+
{main}
+
+ {sidebarChildren}
+
+
+
+ );
+}
+
+/**
+ * The pinned bar under the NavBar. Baked chrome: a 40px-tall bar with a bottom border and the
+ * standard page insets. Compose left/right clusters as child divs — `justify-between` spreads them
+ * (a single child sits at the start).
+ */
+function MetricsLayoutFilters({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ /** Override the baked horizontal padding (the two queue pages want slightly different insets). */
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Whether a grid holds stat tiles (auto height) or charts (a fixed row height). */
+export type MetricsGridKind = "tiles" | "charts";
+
+/**
+ * A grid of tiles with the baked page gutter and grid gap. Columns are derived from the tile count
+ * unless you pass an explicit `columns` spec. Pass `kind="charts"` for a row of chart cards — it
+ * bakes the fixed chart-row height so the cards fill it (no wrapper needed).
+ */
+function MetricsLayoutGrid({
+ children,
+ columns,
+ kind = "tiles",
+}: {
+ children: ReactNode;
+ /** Explicit responsive columns. Omit to derive the layout from the number of tiles. */
+ columns?: GridColumns;
+ /** `"tiles"` (default) sizes to content; `"charts"` bakes the fixed chart-row height. */
+ kind?: MetricsGridKind;
+}) {
+ const resolved = columns ?? columnsForCount(Children.toArray(children).length);
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * The content region below the tiles (tabs / table / list). Full-bleed by default so a list table
+ * spans edge to edge with its own top border; pass `inset` for a padded column (the detail page's
+ * tabs + charts). Separation from the tiles above comes from the scroll column's gap alone (no
+ * extra top margin), so the tile → content step matches the gap between tile rows.
+ */
+function MetricsLayoutContent({
+ children,
+ inset = false,
+}: {
+ children: ReactNode;
+ /** Pad the content into a column (page gutter) instead of letting it span edge to edge. */
+ inset?: boolean;
+}) {
+ return
{children}
;
+}
+
+export const MetricsLayout = {
+ Root: MetricsLayoutRoot,
+ Filters: MetricsLayoutFilters,
+ Grid: MetricsLayoutGrid,
+ Content: MetricsLayoutContent,
+ Sidebar: MetricsLayoutSidebar,
+};
+
+export {
+ MetricsLayoutRoot,
+ MetricsLayoutFilters,
+ MetricsLayoutGrid,
+ MetricsLayoutContent,
+ MetricsLayoutSidebar,
+};
diff --git a/apps/webapp/app/components/metrics/ActivityBarChart.tsx b/apps/webapp/app/components/metrics/ActivityBarChart.tsx
new file mode 100644
index 00000000000..4e361b224b8
--- /dev/null
+++ b/apps/webapp/app/components/metrics/ActivityBarChart.tsx
@@ -0,0 +1,82 @@
+import { type ReactElement, type ReactNode } from "react";
+import { BarChart, ReferenceLine, Tooltip, YAxis } from "recharts";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+
+// Fixed px dims skip ResponsiveContainer's ResizeObserver — otherwise every panel resize
+// re-renders all the charts in a list at once.
+export const ACTIVITY_CHART_WIDTH = 112;
+export const ACTIVITY_CHART_HEIGHT = 24;
+export const ACTIVITY_CHART_PEAK_CLASS =
+ "-mt-1 inline-block min-w-7 text-xxs tabular-nums text-text-dimmed";
+
+type ActivityBarChartProps = {
+ /** Recharts row data; each row is a bucket. The bar `children` read their `dataKey`s off it. */
+ data: ReadonlyArray>;
+ /** Y-axis domain top and the height of the dashed peak line. */
+ max: number;
+ /** The `` element(s) — one stacked series per bar, or a single bar with ``s. */
+ children: ReactNode;
+ /** Recharts `` element for the per-bucket hover card. */
+ tooltip: ReactElement;
+ /** Trailing peak label shown to the right of the chart. */
+ peak: ReactNode;
+ /** Optional tooltip wrapping the peak label. */
+ peakTooltip?: ReactNode;
+ /** Chart width in px. Defaults to the shared ACTIVITY_CHART_WIDTH. */
+ width?: number;
+};
+
+/**
+ * Shared visual frame for the inline activity/backlog mini bar charts (tasks page + queues list).
+ * Owns the fixed dimensions, y-axis, hover tooltip, baseline, dashed peak line, and the trailing
+ * peak label — the single source of truth for how these charts look. Callers supply the bars and
+ * the tooltip content, which is where the two usages differ (stacked per-status vs. single series).
+ */
+export function ActivityBarChart({
+ data,
+ max,
+ children,
+ tooltip,
+ peak,
+ peakTooltip,
+ width = ACTIVITY_CHART_WIDTH,
+}: ActivityBarChartProps) {
+ return (
+
) : (
"–"
diff --git a/apps/webapp/app/components/metrics/MiniLineChart.tsx b/apps/webapp/app/components/metrics/MiniLineChart.tsx
new file mode 100644
index 00000000000..848fab7e85e
--- /dev/null
+++ b/apps/webapp/app/components/metrics/MiniLineChart.tsx
@@ -0,0 +1,230 @@
+import { type ReactNode } from "react";
+import {
+ Line,
+ LineChart,
+ ReferenceLine,
+ ResponsiveContainer,
+ Tooltip,
+ type TooltipProps,
+ YAxis,
+} from "recharts";
+import { formatDateTime } from "~/components/primitives/DateTime";
+import { Header3 } from "~/components/primitives/Headers";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import TooltipPortal from "~/components/primitives/TooltipPortal";
+import {
+ ACTIVITY_CHART_HEIGHT,
+ ACTIVITY_CHART_PEAK_CLASS,
+ ACTIVITY_CHART_WIDTH,
+} from "./ActivityBarChart";
+
+type UnitLabel = { singular: string; plural: string };
+
+/** Extra px above the plot so the hover activeDot at the peak value isn't clipped by the SVG edge. */
+const DOT_HEADROOM = 3;
+
+type MiniLineChartDatum = {
+ date: Date;
+ count: number;
+ /** Raw per-bucket throttled count (tooltip). */
+ throttledCount: number;
+ /** The queued value again, present only around throttled buckets, so the warning overlay
+ * retraces the same line and reads as one line changing colour. */
+ throttledOverlay: number | null;
+};
+
+export type MiniLineChartProps = {
+ /** Equal-width time buckets, oldest first. */
+ data?: number[];
+ /**
+ * Per-bucket throttled counts aligned 1:1 with `data`. Where throttling occurred, the queued
+ * line itself is retraced in the warning colour — one line that changes colour, with the
+ * throttled magnitude carried by the tooltip.
+ */
+ throttled?: number[];
+ /**
+ * Tooltip wording for the overlay buckets. Defaults to "throttled" (the queue
+ * pages); other consumers (e.g. report anomaly windows) pass their own, or
+ * null to omit the overlay line from the tooltip entirely.
+ */
+ overlayLabel?: string | null;
+ /** Epoch ms of the first bucket's start. When omitted, the last bucket is anchored to now. */
+ bucketStartMs?: number;
+ /** Width of each bucket in ms. Defaults to one hour. */
+ bucketIntervalMs?: number;
+ /** Line colour for the queued series. */
+ color?: string;
+ /** Trailing peak scalar shown after the chart. Defaults to the max of the buckets. */
+ peak?: number;
+ /** Format the trailing peak label. Defaults to `toLocaleString`. */
+ formatPeak?: (peak: number) => string;
+ /** Tooltip content shown on hover of the trailing peak label. */
+ peakTooltip?: ReactNode;
+ /** Unit shown in the per-bucket tooltip (e.g. queued, runs). */
+ unitLabel?: UnitLabel;
+ /** Chart width in px. Defaults to the shared ACTIVITY_CHART_WIDTH. Ignored when `fillWidth`. */
+ width?: number;
+ /** Plot height in px. Defaults to the shared ACTIVITY_CHART_HEIGHT. */
+ height?: number;
+ /** Stretch the plot to the container width (via ResponsiveContainer) instead of a fixed px width. */
+ fillWidth?: boolean;
+ /** Show the trailing peak label to the right of the chart. Defaults to true. */
+ showPeak?: boolean;
+};
+
+/**
+ * Inline fixed-size mini line sparkline for list rows, plus a trailing peak label. Presentational —
+ * the caller supplies zero/carry-forward-filled buckets. Renders an em-dash when there's no data.
+ * The queued series is a thin monotone line (no dots) matching the big Backlog chart; stretches
+ * where the queue was throttled retrace the same line in the warning colour. Shares its fixed
+ * dimensions and trailing peak label with {@link ActivityBarChart}, but plots lines instead of bars.
+ */
+export function MiniLineChart({
+ data,
+ throttled,
+ overlayLabel = "throttled",
+ bucketStartMs,
+ bucketIntervalMs,
+ color = "var(--color-tasks)",
+ peak: peakOverride,
+ formatPeak,
+ peakTooltip,
+ unitLabel = { singular: "value", plural: "values" },
+ width = ACTIVITY_CHART_WIDTH,
+ height = ACTIVITY_CHART_HEIGHT,
+ fillWidth = false,
+ showPeak = true,
+}: MiniLineChartProps) {
+ const hasPeakOverride = peakOverride !== undefined;
+ if (!data || data.length === 0 || (data.every((v) => v === 0) && !hasPeakOverride)) {
+ return –;
+ }
+
+ // The overlay only draws where throttling happened. Mapping other buckets to null leaves gaps so
+ // a wholly-zero throttled series never paints over the queued line.
+ const hasThrottled = throttled?.some((v) => v > 0) ?? false;
+
+ const max = Math.max(...data);
+ const peak = peakOverride ?? max;
+
+ // Map each bucket to a dated point so the tooltip can show the window it represents. Buckets are
+ // `intervalMs` wide; if the caller didn't pass the first bucket's start, anchor the last bucket to
+ // now (hourly default).
+ const intervalMs = bucketIntervalMs ?? 3600_000;
+ const startMs = bucketStartMs ?? Date.now() - (data.length - 1) * intervalMs;
+ const chartData: MiniLineChartDatum[] = data.map((count, i) => {
+ const t = throttled?.[i] ?? 0;
+ // Extend the mask one bucket forward (a segment needs both endpoints non-null), so even a
+ // single throttled bucket draws a visible warning stretch.
+ const inOverlay = t > 0 || (throttled?.[i - 1] ?? 0) > 0;
+ return {
+ date: new Date(startMs + i * intervalMs),
+ count,
+ throttledCount: t,
+ throttledOverlay: inOverlay ? count : null,
+ };
+ });
+
+ const chart = (
+
+
+ }
+ allowEscapeViewBox={{ x: true, y: true }}
+ wrapperStyle={{ zIndex: 1000 }}
+ animationDuration={0}
+ />
+
+
+ {hasThrottled && (
+
+ )}
+
+ );
+
+ return (
+
+ {/* +DOT_HEADROOM of extra height, spent as top margin, so the hover activeDot at the peak
+ isn't clipped by the SVG edge while the plotted area stays `height` tall. */}
+
+ {/* Fixed px dims skip ResponsiveContainer's ResizeObserver (see ActivityBarChart); with
+ fillWidth we opt back into it so the plot stretches to the block. */}
+ {fillWidth ? (
+
+ {chart}
+
+ ) : (
+ chart
+ )}
+
setIsHovered(true)}
- onMouseLeave={() => setIsHovered(false)}
>
- {hiddenLabel ? (
- {children}
+ {sortable ? (
+ // Only the sort arrows toggle sorting — the label (and info tooltip) are not clickable, so
+ // clicking the header text does nothing. Order is always title → info icon → sort arrows.
+
+
+
+ {/* Live "right now" state of the whole queue — independent of the time filter above.
+ QueueStats renders the stat-tile grid slot (see MetricsLayout.Grid inside it). */}
+
+
+ {/* Tabs + charts share the padded (inset) column. Both tabs always render; the keys tab
+ shows an empty state when the queue has no concurrency keys. */}
+
+ {view === "keys" ? (
+ hasKeys ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ {/* The per-key table is full-bleed (no inset), matching the Queues list table, so it spans
+ edge to edge. The drill-down that opens under it is charts, so it stays in the padded
+ column. */}
+ {view === "keys" && hasKeys ? (
+ <>
+
+
+
+ {selectedKey ? (
+
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+ );
+}
+
+// Inline colour swatch for tooltip copy — matches the chart legend swatch (rounded-[2px]) and is
+// nudged up 1px so it sits on the text baseline.
+function ColorSwatch({ color }: { color: string }) {
+ return (
+
+ );
+}
+
+function OverviewCharts({
+ ids,
+ timeRange,
+ queueName,
+}: {
+ ids: Ids;
+ timeRange: TimeRangeParams;
+ queueName: string;
+}) {
+ const zoomToTimeFilter = useZoomToTimeFilter();
+ return (
+
+
+
+ How many runs are executing at once () versus
+ the queue's limit (
+ ). Turns color when it reaches the limit.
+ >
+ }
+ showLegend
+ className="aspect-[2/1]"
+ query={`SELECT timeBucket() AS t, max(max_running) AS running, max(max_limit) AS limit\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
+ fillGaps
+ ids={ids}
+ timeRange={timeRange}
+ queueName={queueName}
+ series={[
+ // Limit first so the grey reference draws underneath; Running (purple) sits on top.
+ { key: "limit", label: "Limit", color: COLORS.limit },
+ { key: "running", label: "Running", color: COLORS.running },
+ ]}
+ // Recolour Running above the limit line with a gradient split, so it's orange only where
+ // it's actually over the limit — not on the way up. The threshold reads off the (roughly
+ // constant) limit series.
+ thresholdStroke={{
+ series: "running",
+ valueFromSeries: "limit",
+ aboveColor: "var(--color-warning)",
+ }}
+ // The limit is a config value emitted only while the queue is active; back-fill its
+ // leading zeros so the reference line doesn't start with a false 0→limit step.
+ carryBackfill={["limit"]}
+ />
+
+
+ Runs arriving ( Enqueued) versus starting (
+ Started). Turns{" "}
+ color when Started falls behind.
+ >
+ }
+ showLegend
+ extraLegend={[{ color: "var(--color-warning)", label: "Falling behind" }]}
+ className="aspect-[2/1]"
+ query={`SELECT timeBucket() AS t,\n deltaSumTimestampMerge(enqueue_delta) AS enqueued,\n deltaSumTimestampMerge(started_delta) AS started\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
+ fillGaps
+ ids={ids}
+ timeRange={timeRange}
+ queueName={queueName}
+ series={[
+ // Enqueued is the neutral grey reference (same grey as the Limit line on Concurrency);
+ // Started is the accent — purple while keeping up, warning where it drops below Enqueued.
+ { key: "enqueued", label: "Enqueued", color: COLORS.limit },
+ { key: "started", label: "Started", color: COLORS.running },
+ ]}
+ warningOverlay={{ series: "started", below: "enqueued" }}
+ />
+
+
+ How often runs were held back by a limit ({" "}
+ color).
+ >
+ }
+ className="aspect-[2/1] sm:col-span-2 sm:aspect-[4/1]"
+ query={`SELECT timeBucket() AS t, sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
+ fillGaps
+ ids={ids}
+ timeRange={timeRange}
+ queueName={queueName}
+ series={[{ key: "throttled", label: "Throttled", color: COLORS.throttled }]}
+ />
+
+
+ );
+}
+
+type CkBreakdown = {
+ totalBackloggedKeys: number;
+ keys: Array<{
+ concurrencyKey: string;
+ queued: number;
+ running: number;
+ oldestEnqueuedAt: number;
+ }>;
+};
+
+// Standard empty state for a queue that has no concurrency keys (no live keys and no CK history).
+// Small, centered panel — same pattern as the runs page's "Create your first task" state.
+function ConcurrencyKeysBlankState() {
+ return (
+
+
+ Concurrency docs
+
+ }
+ >
+
+ This queue doesn't use concurrency keys. Add concurrencyKey to
+ your task to shard the queue per tenant/user.
+
+
+
+ );
+}
+
+function ConcurrencyKeyCharts({
+ breakdown,
+ loadedAt,
+ ids,
+ timeRange,
+ queueName,
+}: {
+ breakdown: CkBreakdown;
+ loadedAt: number;
+ ids: Ids;
+ timeRange: TimeRangeParams;
+ queueName: string;
+}) {
+ const zoomToTimeFilter = useZoomToTimeFilter();
+ const { value, replace } = useSearchParams();
+ // Same key search as the table: narrows the per-key charts too.
+ const keyFilter = value("query")?.trim().toLowerCase() || undefined;
+
+ // The live most-starved key: among keys with a live backlog, the one whose oldest waiting run
+ // has been waiting longest right now. Names the culprit on the "Worst key wait" card so the chart
+ // (which shows how bad, not who) points at a tenant you can click through to.
+ const worstKeyNow = useMemo(() => {
+ let worst: { key: string; waitMs: number } | null = null;
+ for (const k of breakdown.keys) {
+ if (k.queued <= 0) continue;
+ const waitMs = Math.max(0, loadedAt - k.oldestEnqueuedAt);
+ if (!worst || waitMs > worst.waitMs) worst = { key: k.concurrencyKey, waitMs };
+ }
+ return worst;
+ }, [breakdown, loadedAt]);
+
+ return (
+ /* Per-key breakdown: which keys hold the backlog / do the work. */
+
+
+ );
+}
+
+// One page of the paginated per-key table. The ClickHouse tier is the authority (ranked by peak
+// backlog over the window, with the total on every row so page + count are a single scan); each
+// page's keys are enriched with live "now" counts from Redis server-side. Fetched per page rather
+// than capped at 50, so high-cardinality queues (tens of thousands of keys) page through instead
+// of silently truncating. See resources.queues.concurrency-keys.
+function useConcurrencyKeys(opts: {
+ ids: Ids;
+ timeRange: TimeRangeParams;
+ queueName: string;
+ search: string;
+ page: number;
+}) {
+ const { ids, timeRange, queueName, search, page } = opts;
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const abortRef = useRef(null);
+
+ const body = useMemo(
+ () =>
+ JSON.stringify({
+ organizationId: ids.organizationId,
+ projectId: ids.projectId,
+ environmentId: ids.environmentId,
+ queueName,
+ period: timeRange.period,
+ from: timeRange.from,
+ to: timeRange.to,
+ search,
+ page,
+ }),
+ [
+ ids.organizationId,
+ ids.projectId,
+ ids.environmentId,
+ queueName,
+ timeRange.period,
+ timeRange.from,
+ timeRange.to,
+ search,
+ page,
+ ]
+ );
+
+ const load = useCallback(() => {
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+ setIsLoading(true);
+ fetch("/resources/queues/concurrency-keys", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body,
+ signal: controller.signal,
+ })
+ .then((res) => res.json() as Promise)
+ .then((res) => {
+ if (controller.signal.aborted) return;
+ setData(res);
+ setIsLoading(false);
+ })
+ .catch((error) => {
+ if (error instanceof DOMException && error.name === "AbortError") return;
+ if (!controller.signal.aborted) {
+ setData({ success: false, error: error?.message ?? "Network error" });
+ setIsLoading(false);
+ }
+ });
+ }, [body]);
+
+ useEffect(() => {
+ load();
+ return () => abortRef.current?.abort();
+ }, [load]);
+
+ // Keep the live "now" counts fresh without a manual reload.
+ useInterval({
+ interval: 30_000,
+ onLoad: false,
+ onFocus: true,
+ pauseWhenHidden: true,
+ callback: load,
+ });
+
+ return { data, isLoading };
+}
+
+// Paginated per-key table: which keys hold the backlog / do the work. Clicking a key pins the
+// drill-down charts via the `key` search param.
+function KeyStatsTable({
+ ids,
+ timeRange,
+ queueName,
+}: {
+ ids: Ids;
+ timeRange: TimeRangeParams;
+ queueName: string;
+}) {
+ const { value, replace, del } = useSearchParams();
+ const selectedKey = value("key");
+ const search = value("query")?.trim() ?? "";
+ const page = Math.max(1, Number(value("page")) || 1);
+
+ const { data, isLoading } = useConcurrencyKeys({ ids, timeRange, queueName, search, page });
+
+ const rows: ConcurrencyKeyRow[] = data?.success ? data.rows : [];
+ const total = data?.success ? data.total : 0;
+ const perPage = data?.success ? data.perPage : 25;
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
+ // Only show a skeleton before the first response; keep prior rows visible while revalidating.
+ const showLoading = isLoading && !data;
+
+ // Recover from a page past the end: narrowing the time range (or a stale bookmarked URL) can
+ // leave `page` beyond the result set, which comes back empty with the pagination control — shown
+ // only when there's >1 page — hidden, stranding the reader. Once a response settles empty on a
+ // page > 1, snap back to page 1 (whose data is fetched fresh) so there's always a way out.
+ useEffect(() => {
+ if (!isLoading && data?.success && data.rows.length === 0 && page > 1) {
+ del("page");
+ }
+ }, [isLoading, data, page, del]);
+
+ return (
+
+ {/* Title bar above the table, shown only when there's more than one page: the section title
+ on the left, prev/next pagination on the right. Hidden entirely for a single page. */}
+ {totalPages > 1 ? (
+
+ Concurrency keys
+
+
+ ) : null}
+ {/* Full-bleed, edge-to-edge like the Queues list table: a top border, no rounded side box. */}
+
+
+ This key: waiting (Queued, color) vs running (
+ color).
+ >
+ }
+ className="aspect-[2/1]"
+ query={`SELECT timeBucket() AS t, max(max_queued) AS queued, max(max_running) AS running\nFROM queue_metrics_by_key\nWHERE ${pin}\nGROUP BY t\nORDER BY t`}
+ fillGaps
+ ids={ids}
+ timeRange={timeRange}
+ queueName={queueName}
+ series={[
+ // Running is the grey reference underneath; Queued (the backlog we care about) is the purple accent on top.
+ { key: "running", label: "Running", color: COLORS.limit },
+ { key: "queued", label: "Queued", color: COLORS.running },
+ ]}
+ />
+
+ 0, round(sum(wait_ms_sum) / sum(wait_ms_count)), 0) AS wait\nFROM queue_metrics_by_key\nWHERE ${pin}\nGROUP BY t\nORDER BY t`}
+ ids={ids}
+ timeRange={timeRange}
+ queueName={queueName}
+ valueFormat={formatWaitMs}
+ series={[{ key: "wait", label: "Mean delay", color: COLORS.running }]}
+ />
+
+
+ );
+}
+
+// Live "right now" snapshot of the whole queue: a hybrid feed. The loader (Redis/PG) supplies the
+// first paint so these blocks render instantly; after that a tiny 15s ClickHouse poll keeps them
+// fresh, always reading the newest gauge row and falling back to the loader values until the first
+// poll lands (so we never flash 0). These blocks never change with the filter. Period trends
+// (backlog, throughput, delay over time) live in the charts below.
+// Oldest-wait threshold for the warning tint: the head of the queue sitting unstarted this long
+// signals the queue is stuck, not just busy.
+const OLDEST_WAIT_WARNING_MS = 5 * 60_000;
+
+// How recent the newest ClickHouse gauge bucket must be to drive the live blocks. Above the 10s
+// bucket + pipeline lag; past it we treat the queue as idle and fall back to the loader value.
+const LIVE_GAUGE_FRESH_MS = 90_000;
+
+function QueueStats({
+ queue,
+ environmentConcurrencyLimit,
+ queuedRunsPath,
+ oldestWaitMs,
+ ids,
+ timeRange,
+ queueName,
+}: {
+ // Carries the percent override source-of-truth (not part of the shared QueueItem contract) so the
+ // override dialog reopens in percent mode for percent-based overrides.
+ queue: QueueItem & { concurrencyLimitOverridePercent: number | null };
+ environmentConcurrencyLimit: number;
+ queuedRunsPath: string;
+ oldestWaitMs: number | null;
+ ids: Ids;
+ timeRange: TimeRangeParams;
+ queueName: string;
+}) {
+ // Live "now" is empty for a queue that just drained, so add the window peak/worst as a dimmed
+ // caption (styled like "bursts up to 50" on the Queues list) — a quiet queue still shows how
+ // backed up it got recently. "worst_wait" is the window's worst head-of-line wait (max of the
+ // oldest still-waiting run's age), the same metric as the live "Oldest wait" headline — not the
+ // scheduling-delay percentiles of runs that already started. Only concurrency-keyed queues record
+ // this (max_ck_wait_ms), so it's 0/absent for non-keyed queues.
+ const { rows } = useQueueMetric(
+ `SELECT max(max_queued) AS peak_queued,\n max(max_ck_wait_ms) AS worst_wait\nFROM queue_metrics`,
+ { ids, timeRange, queueName }
+ );
+ const peakQueued = rows[0] ? toNumber(rows[0].peak_queued) : 0;
+ const worstWaitMs = rows[0] ? toNumber(rows[0].worst_wait) : 0;
+
+ // Latest gauges from ClickHouse, polled every 15s so the live blocks keep ticking after first
+ // paint. Read the newest bucket (largest t); until the first poll lands liveRows is empty and the
+ // *Live values stay null, so the blocks show the loader values instead of flashing 0.
+ const { rows: liveRows } = useQueueMetric(
+ `SELECT timeBucket() AS t, max(max_running) AS running, max(max_queued) AS queued, max(max_limit) AS q_limit, max(max_ck_wait_ms) AS ck_wait FROM queue_metrics GROUP BY t ORDER BY t`,
+ {
+ ids,
+ timeRange: { period: "15m", from: null, to: null },
+ defaultPeriod: "15m",
+ queueName,
+ refreshIntervalMs: 15_000,
+ }
+ );
+ // Gauges are only emitted while the queue is active, so a drained queue's newest bucket is a past
+ // one holding its last non-zero reading. Trust the CH gauge only when its newest bucket is recent
+ // (covers the 10s bucket + pipeline lag); once it ages out we fall back to the loader's live
+ // Redis/PG value instead of lingering on a stale count.
+ const latest = liveRows.length > 0 ? liveRows[liveRows.length - 1] : undefined;
+ const latestBucketMs = latest ? clickhouseTimeToMs(latest.t) : NaN;
+ const liveFresh =
+ Number.isFinite(latestBucketMs) && Date.now() - latestBucketMs < LIVE_GAUGE_FRESH_MS;
+ const fresh = latest && liveFresh ? latest : undefined;
+ const runningLive = fresh ? toNumber(fresh.running) : null;
+ const queuedLive = fresh ? toNumber(fresh.queued) : null;
+ const limitLive = fresh ? toNumber(fresh.q_limit) : null;
+ const ckWaitLive = fresh ? toNumber(fresh.ck_wait) : null;
+
+ // Prefer CH once it has landed; loader values before that.
+ const runningDisplay = runningLive ?? queue.running;
+ const queuedDisplay = queuedLive ?? queue.queued;
+ // Limit is queue config, not a live signal: keep the loader's value. Only if the loader had none
+ // do we fall back to the CH gauge for display.
+ const limitDisplay = queue.concurrencyLimit ?? (limitLive || null);
+ // Keyed queues report head-of-line wait via CH (max_ck_wait_ms); use it as the live headline when
+ // present. Non-keyed queues have no CH signal, so they stay on the loader value.
+ const oldestWaitDisplayMs = ckWaitLive !== null && ckWaitLive > 0 ? ckWaitLive : oldestWaitMs;
+
+ return (
+
+
+ 0 ? `peak ${formatNumberCompact(peakQueued)}` : undefined}
+ suffixClassName="text-text-dimmed"
+ accessory={
+
+
+
+ }
+ />
+ 0
+ ? formatWaitMs(oldestWaitDisplayMs)
+ : "0"
+ }
+ valueClassName={cn(
+ "tabular-nums",
+ oldestWaitDisplayMs !== null &&
+ oldestWaitDisplayMs >= OLDEST_WAIT_WARNING_MS &&
+ "text-warning"
+ )}
+ suffix={worstWaitMs > 0 ? `worst ${formatWaitMs(worstWaitMs)}` : undefined}
+ suffixClassName="text-text-dimmed"
+ />
+
+ );
+}
+
+/** Live concurrency as a single block: running vs limit with a utilization bar, so "how close to
+ * the ceiling" reads at a glance instead of two separate numbers. Warning-tinted at/over the limit. */
+function ConcurrencyBlock({
+ running,
+ limit,
+ paused = false,
+ loading,
+ accessory,
+}: {
+ running: number;
+ limit: number | null;
+ paused?: boolean;
+ loading?: boolean;
+ accessory?: ReactNode;
+}) {
+ const atLimit = limit !== null && limit > 0 && running >= limit;
+ const pct = limit && limit > 0 ? Math.min(100, Math.round((running / limit) * 100)) : 0;
+ return (
+
+
+
+ Concurrency
+ {paused ? paused : null}
+
+ {accessory ?
{accessory}
: null}
+
+ {loading ? (
+
+ ) : (
+
+
+
+ {running.toLocaleString()}
+
+
+ / {limit !== null ? limit.toLocaleString() : "∞"}
+
+ {limit !== null && limit > 0 && (
+
+ {/* Separator so the limit and the percentage don't read as one number
+ (e.g. "/ 25" + "44%" mashing into "2544%"). */}
+ ·
+ {pct}% of limit
+
+ )}
+
+ {limit !== null && limit > 0 && (
+
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
index 95dbe5f7926..7ced0a0b492 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
@@ -1,7 +1,6 @@
import {
ArrowUturnLeftIcon,
BoltSlashIcon,
- BookOpenIcon,
ChevronDownIcon,
ChevronRightIcon,
InformationCircleIcon,
@@ -106,10 +105,11 @@ import { logger } from "~/services/logger.server";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
import { requireUserId } from "~/services/session.server";
import { rbac } from "~/services/rbac.server";
+import { runAgentPageContext } from "~/components/dashboard-agent/suggested-prompts";
import { cn } from "~/utils/cn";
+import type { Handle } from "~/utils/handle";
import { lerp } from "~/utils/lerp";
import {
- docsPath,
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
@@ -267,6 +267,14 @@ async function runWritePermissions(request: Request, userId: string, organizatio
return { canReplayRun: canWriteRun, canCancelRun: canWriteRun };
}
+// Tell the dashboard agent which run it's looking at, and whether something is
+// wrong with it: a recent failure or a run stuck waiting. Both come from fields
+// the loader already returns (`run.status`, `run.completedAt`, and the task id off
+// the run's own span), so this costs no queries — see `runAgentPageContext`.
+export const handle: Handle = {
+ agentPageContext: (data) => runAgentPageContext(data),
+};
+
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
@@ -513,9 +521,6 @@ export default function Page() {
-
- Run docs
-