chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user