chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,133 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import type { ChartBlock } from "@internal/dashboard-agent";
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 { 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.
type MetricResponse =
| { success: false; error: string }
| {
success: true;
data: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
timeRange: { from: string; to: string };
};
};
type ChartState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "ready";
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
timeRange?: { from: string; to: string };
};
export function AgentChart({ block }: { block: ChartBlock }) {
const organization = useOptionalOrganization();
const project = useOptionalProject();
const environment = useOptionalEnvironment();
const [state, setState] = useState<ChartState>({ status: "loading" });
const organizationId = organization?.id;
const projectId = project?.id;
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).
if (!block.query) return;
if (!organizationId || !projectId || !environmentId) {
setState({ status: "error", error: "No environment context to run the query." });
return;
}
const controller = new AbortController();
setState({ status: "loading" });
fetch("/resources/metric", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: block.query,
organizationId,
projectId,
environmentId,
scope: "environment",
period: block.period ?? null,
from: null,
to: null,
}),
signal: controller.signal,
})
.then(async (res) => (await res.json()) as MetricResponse)
.then((data) => {
if (controller.signal.aborted) return;
if (!data.success) {
setState({ status: "error", error: data.error });
} else {
setState({
status: "ready",
rows: data.data.rows,
columns: data.data.columns,
timeRange: data.data.timeRange,
});
}
})
.catch((err) => {
if (controller.signal.aborted) return;
setState({ status: "error", error: err?.message ?? "The query failed to run." });
});
return () => controller.abort();
}, [block.query, block.period, organizationId, projectId, environmentId]);
const config: ChartConfiguration = {
chartType: block.chartType,
xAxisColumn: block.xAxisColumn,
yAxisColumns: block.yAxisColumns ?? [],
groupByColumn: block.groupByColumn ?? null,
stacked: block.stacked ?? false,
sortByColumn: null,
sortDirection: "desc",
aggregation: block.aggregation ?? "sum",
};
return (
<div className="overflow-hidden rounded-lg border border-border-bright bg-background-dimmed">
{block.title ? (
<div className="border-b border-grid-bright bg-background-bright px-3 py-2 text-xs font-medium text-text-dimmed">
{block.title}
</div>
) : null}
<div className="h-64 w-full p-2">
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-xs text-text-dimmed">
<Spinner className="size-3" />
Running query
</div>
) : state.status === "error" ? (
<div className="flex h-full items-center justify-center px-3 text-center text-xs text-error">
{state.error}
</div>
) : (
<QueryResultsChart
rows={state.rows}
columns={state.columns}
config={config}
timeRange={state.timeRange}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { useState } from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { DashboardAgentPanel } from "./DashboardAgentPanel";
import { DashboardAgentProvider } from "./dashboardAgentLauncher";
/**
* 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.
*/
export function DashboardAgent({
children,
hasAccess = false,
}: {
children: React.ReactNode;
hasAccess?: boolean;
}) {
const [open, setOpen] = useState(false);
if (!hasAccess) {
return <div className="h-full min-h-0">{children}</div>;
}
return (
<DashboardAgentProvider value={{ open, setOpen }}>
{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>
) : (
<div className="h-full min-h-0 overflow-hidden">{children}</div>
)}
</DashboardAgentProvider>
);
}
@@ -0,0 +1,194 @@
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "@ai-sdk/react";
import type { dashboardAgent } from "@internal/dashboard-agent";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentMessages } from "./DashboardAgentMessages";
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
// 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.
export type DashboardAgentSession = {
publicAccessToken: string;
lastEventId?: string;
};
// Per-turn context for the agent. Matches the agent's clientDataSchema input.
export type DashboardAgentClientData = {
userId: string;
organizationId: string;
projectId?: string;
environmentId?: string;
currentPage?: string;
};
/**
* 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.
*/
export function DashboardAgentChat({
chatId,
initialMessages,
session,
clientData,
apiOrigin,
actionPath,
projectSlug,
environmentSlug,
currentPage,
pendingFirstMessage,
streaming,
onTurnSettled,
}: {
chatId: string;
initialMessages: UIMessage[];
session: DashboardAgentSession | null;
clientData: DashboardAgentClientData;
apiOrigin: string;
actionPath: string;
projectSlug: string;
environmentSlug: string;
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.
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;
onTurnSettled: () => void;
}) {
const [input, setInput] = useState("");
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) => {
if (ctx.endpoint !== "in") return globalThis.fetch(url, init);
const { pathname, search } = new URL(url);
return globalThis.fetch(`${actionPath}/in${pathname}${search}`, init);
},
clientData,
sessions: session
? {
[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.
isStreaming: streaming ?? false,
},
}
: undefined,
startSession: async ({ chatId }) => {
const body = new FormData();
body.set("intent", "start");
body.set("chatId", chatId);
body.set("clientData", JSON.stringify(clientData));
const res = await fetch(actionPath, { method: "POST", body });
const data = (await res.json()) as { publicAccessToken?: string; error?: string };
if (!res.ok || !data.publicAccessToken) {
throw new Error(data.error ?? "The chat couldn't start.");
}
return { publicAccessToken: data.publicAccessToken };
},
accessToken: async ({ chatId }) => {
const body = new FormData();
body.set("intent", "token");
body.set("chatId", chatId);
const res = await fetch(actionPath, { method: "POST", body });
const data = (await res.json()) as { token?: string; error?: string };
if (!res.ok || !data.token) {
throw new Error(data.error ?? "Couldn't refresh the chat token.");
}
return data.token;
},
});
const {
messages,
sendMessage,
status,
stop: aiStop,
error,
} = 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";
// Cold start: trigger the first turn by sending the pending message once.
const sentFirst = useRef(false);
useEffect(() => {
if (pendingFirstMessage && !sentFirst.current) {
sentFirst.current = true;
void sendMessage({ text: pendingFirstMessage });
}
}, [pendingFirstMessage, sendMessage]);
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed || isStreaming) return;
setInput("");
void sendMessage({ text: trimmed });
},
[isStreaming, sendMessage]
);
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.
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]);
return (
<>
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
{messages.length === 0 ? (
<DashboardAgentSuggestedPrompts onSelect={submit} />
) : (
<DashboardAgentMessages messages={messages} isThinking={isThinking} error={error} />
)}
<DashboardAgentComposer
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={stop}
isStreaming={isStreaming}
/>
</>
);
}
@@ -0,0 +1,58 @@
import { PaperAirplaneIcon, StopIcon } from "@heroicons/react/20/solid";
import { useRef } from "react";
import { Button } from "~/components/primitives/Buttons";
import { cn } from "~/utils/cn";
export function DashboardAgentComposer({
value,
onChange,
onSubmit,
onStop,
isStreaming,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
isStreaming: boolean;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
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">
<textarea
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onSubmit();
}
}}
placeholder="Type a message…"
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"
)}
/>
{isStreaming ? (
<Button variant="danger/small" LeadingIcon={StopIcon} onClick={onStop}>
Stop
</Button>
) : (
<Button
variant="primary/small"
LeadingIcon={PaperAirplaneIcon}
onClick={onSubmit}
disabled={!value.trim()}
>
Send
</Button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
export function DashboardAgentContextBanner({
projectSlug,
environmentSlug,
currentPage,
}: {
projectSlug: string;
environmentSlug: string;
currentPage: string;
}) {
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">
<span className="shrink-0">Context:</span>
<span className="truncate font-medium text-text-bright">{projectSlug}</span>
<span>/</span>
<span className="truncate">{environmentSlug}</span>
<span>/</span>
<span className="truncate capitalize">{currentPage}</span>
</div>
);
}
@@ -0,0 +1,53 @@
import { useCallback, useState } from "react";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
/**
* 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.
*/
export function DashboardAgentDraft({
onSubmit,
projectSlug,
environmentSlug,
currentPage,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
environmentSlug: string;
currentPage: string;
}) {
const [input, setInput] = useState("");
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setInput("");
onSubmit(trimmed);
},
[onSubmit]
);
return (
<>
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
<DashboardAgentSuggestedPrompts onSelect={submit} />
<DashboardAgentComposer
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={() => {}}
isStreaming={false}
/>
</>
);
}
@@ -0,0 +1,57 @@
import { ClockIcon, PencilSquareIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
export function DashboardAgentHeader({
view,
onNewChat,
onToggleHistory,
onClose,
}: {
view: "chat" | "history";
onNewChat: () => void;
onToggleHistory: () => void;
onClose: () => void;
}) {
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"}
/>
<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,81 @@
import { PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import { DateTime } from "~/components/primitives/DateTime";
import { Paragraph } from "~/components/primitives/Paragraph";
import { cn } from "~/utils/cn";
// Date fields arrive as strings over the loader's JSON.
export type DashboardAgentChat = {
id: string;
title: string;
lastMessageAt: string | null;
updatedAt: string;
};
export function DashboardAgentHistory({
chats,
currentChatId,
onSelect,
onNewChat,
onDelete,
}: {
chats: DashboardAgentChat[];
currentChatId: string;
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>
{chats.length === 0 ? (
<Paragraph variant="small" className="p-2 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>
)}
</div>
</div>
);
}
@@ -0,0 +1,84 @@
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 { 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 {
if (!message.parts?.some((p) => p.type === "step-start")) return message;
return { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
}
// 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({
message,
}: {
message: UIMessage;
}) {
if (message.role !== "assistant" || !message.parts?.some((p) => viewSpecFor(p))) {
return <MessageBubble message={message} />;
}
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>
);
});
// 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({
messages,
isThinking,
error,
}: {
messages: UIMessage[];
isThinking: boolean;
error?: Error;
}) {
const rootRef = useAutoScrollToBottom([messages, isThinking]);
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>
);
}
@@ -0,0 +1,282 @@
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 { useApiOrigin } from "~/hooks/useApiOrigin";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import {
DashboardAgentChat,
type DashboardAgentClientData,
type DashboardAgentSession,
} from "./DashboardAgentChat";
import { DashboardAgentDraft } from "./DashboardAgentDraft";
import { DashboardAgentHeader } from "./DashboardAgentHeader";
import {
DashboardAgentHistory,
type DashboardAgentChat as DashboardAgentChatListItem,
} from "./DashboardAgentHistory";
// 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}`;
type ActiveChat = {
chatId: 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`.
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 }) {
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 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 clientData = useMemo<DashboardAgentClientData>(
() => ({
userId: user.id,
organizationId: organization.id,
projectId: project.id,
environmentId: environment.id,
currentPage: location.pathname,
}),
[user.id, organization.id, project.id, environment.id, location.pathname]
);
const loadHistory = useCallback(async () => {
const res = await fetch(actionPath);
if (res.ok) {
const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] };
setChats(data.chats ?? []);
}
}, [actionPath]);
// Bumped on each open so a slower earlier open can't overwrite a newer one
// when chats are switched rapidly.
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);
}
} finally {
if (seq === openChatRequestSeq.current) setLoading(false);
}
},
[actionPath]
);
// 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 {
const userMessage: UIMessage = {
id: generateFriendlyId("msg"),
role: "user",
parts: [{ type: "text", text }],
};
const body = new FormData();
body.set("intent", "create");
body.set("message", JSON.stringify(userMessage));
body.set("clientData", JSON.stringify(clientData));
const res = await fetch(actionPath, { method: "POST", body });
const data = (await res.json()) as {
chatId?: string;
publicAccessToken?: string;
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) {
setActive(null);
return;
}
setActive({
chatId: data.chatId,
messages: data.headStarted ? [userMessage] : [],
session: { publicAccessToken: data.publicAccessToken },
pendingFirstMessage: data.headStarted ? undefined : text,
streaming: data.headStarted,
});
} finally {
if (seq === openChatRequestSeq.current) setLoading(false);
}
},
[actionPath, clientData]
);
// 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 */
}
if (stored) void openChat(stored);
}, [openChat, storageKey]);
// Persist the active chat as the one to restore next time.
useEffect(() => {
if (!active?.chatId) return;
try {
window.localStorage.setItem(storageKey, active.chatId);
} catch {
/* ignore */
}
}, [active?.chatId, storageKey]);
const newChat = useCallback(() => {
// Invalidate any in-flight open/create so its result can't replace the draft.
openChatRequestSeq.current += 1;
setLoading(false);
setView("chat");
setActive(null);
}, []);
const switchChat = useCallback(
(id: string) => {
void openChat(id);
},
[openChat]
);
const deleteChat = useCallback(
async (id: string) => {
const body = new FormData();
body.set("intent", "delete");
body.set("chatId", id);
await fetch(actionPath, { method: "POST", body });
if (id === active?.chatId) newChat();
void loadHistory();
},
[actionPath, active?.chatId, newChat, loadHistory]
);
const toggleHistory = useCallback(() => {
setView((v) => {
if (v === "chat") void loadHistory();
return v === "chat" ? "history" : "chat";
});
}, [loadHistory]);
return (
<div className="flex h-full flex-col bg-background-bright animate-in slide-in-from-right-2 duration-150">
<DashboardAgentHeader
view={view}
onNewChat={newChat}
onToggleHistory={toggleHistory}
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}
/>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { SparklesIcon } from "@heroicons/react/20/solid";
import { Paragraph } from "~/components/primitives/Paragraph";
// 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.",
];
export function DashboardAgentSuggestedPrompts({
onSelect,
}: {
onSelect: (prompt: string) => void;
}) {
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"
>
{prompt}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,223 @@
import { Link } from "@remix-run/react";
import type { DiagnosisBlock } from "@internal/dashboard-agent";
import { Badge } from "~/components/primitives/Badge";
import { toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
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";
// 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.
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",
};
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 EVIDENCE_LABELS: Record<DiagnosisBlock["evidence"][number]["type"], string> = {
error: "Error",
failed_span: "Failed span",
child_run: "Child run",
logs: "Logs",
deploy: "Deploy",
source: "Source",
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 {
const organization = useOptionalOrganization();
const project = useOptionalProject();
const environment = useOptionalEnvironment();
if (!organization || !project || !environment) return null;
return v3RunPath(organization, project, environment, { friendlyId: 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)}>
{runId}
</Link>
);
}
// 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)) {
return <RunLink runId={reference} className="font-mono text-xs" />;
}
const safeUrl = toSafeUrl(reference);
if (safeUrl) {
return (
<a
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-indigo-400 underline hover:text-indigo-300"
>
{reference}
</a>
);
}
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>
);
}
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>
);
}
export function RunDiagnosisCard({ block }: { block: DiagnosisBlock }) {
const evidence = block.evidence ?? [];
const nextSteps = block.nextSteps ?? [];
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>
<div className="space-y-3 px-3 py-3">
<p className="text-sm text-text-bright">{block.summary}</p>
<Section title="Likely cause">
<p className="text-sm text-text-dimmed">{block.likelyCause}</p>
</Section>
{evidence.length > 0 ? (
<Section title="Evidence">
<ul className="space-y-1.5">
{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">
{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}
</li>
))}
</ul>
</Section>
) : null}
{block.impact ? (
<Section title="Impact">
<p className="text-sm text-text-dimmed">{block.impact}</p>
</Section>
) : null}
{nextSteps.length > 0 ? (
<Section title="Next steps">
<ol className="list-decimal space-y-1 pl-4">
{nextSteps.map((step, i) => (
<li key={i} className="text-sm text-text-dimmed">
{step}
</li>
))}
</ol>
</Section>
) : null}
{actions.length > 0 ? <DiagnosisActions actions={actions} /> : null}
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<h4 className="text-xs font-medium uppercase tracking-wide text-text-dimmed">{title}</h4>
{children}
</div>
);
}
@@ -0,0 +1,48 @@
import { ChatBubbleLeftRightIcon, ChevronDoubleRightIcon } from "@heroicons/react/20/solid";
import { createContext, useContext } from "react";
import { cn } from "~/utils/cn";
type DashboardAgentContextValue = {
open: boolean;
setOpen: (open: boolean) => 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.
export function useDashboardAgent() {
return useContext(DashboardAgentContext);
}
export function DashboardAgentLauncher() {
const agent = useDashboardAgent();
if (!agent) {
return null;
}
const { open, setOpen } = agent;
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>
);
}
@@ -0,0 +1,29 @@
import type { ViewBlock } from "@internal/dashboard-agent";
import { AgentChart } from "./AgentChart";
import { RunDiagnosisCard } from "./RunDiagnosisCard";
// The render registry for the dashboard agent's view catalog — our small
// "generative UI" layer. The agent emits a `render_view` tool call whose output
// is `{ blocks: ViewBlock[] }` (a spec drawn from the catalog defined in
// internal-packages/dashboard-agent). Here we map each block `type` to its
// component. Unknown types are skipped, so an older/newer agent can never
// render arbitrary content — same guarantee a generative-UI framework gives,
// without the dependency. Add a block by adding a `case` here and a union
// member in the package's `viewBlockSchema`.
export function ViewBlocks({ blocks }: { blocks: ViewBlock[] }) {
if (!Array.isArray(blocks)) return null;
return (
<div className="space-y-2">
{blocks.map((block, i) => {
switch (block.type) {
case "diagnosis":
return <RunDiagnosisCard key={i} block={block} />;
case "chart":
return <AgentChart key={i} block={block} />;
default:
return null;
}
})}
</div>
);
}