Files
2026-07-13 13:32:57 +08:00

2237 lines
82 KiB
TypeScript

import * as Ariakit from "@ariakit/react";
import { ArrowPathIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { type MetaFunction, useFetcher } from "@remix-run/react";
import { json, type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { AnimatePresence, motion } from "framer-motion";
import { ClipboardCheckIcon, ClipboardIcon, GitBranchPlusIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { CodeBlock } from "~/components/code/CodeBlock";
import { TextEditor } from "~/components/code/TextEditor";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { ModelsFilter } from "~/components/metrics/ModelsFilter";
import { OperationsFilter } from "~/components/metrics/OperationsFilter";
import { ProvidersFilter } from "~/components/metrics/ProvidersFilter";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { PermissionButton } from "~/components/primitives/PermissionButton";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import {
Table,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
type ResizableSnapshot,
} from "~/components/primitives/Resizable";
import {
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextArea } from "~/components/primitives/TextArea";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useInterval } from "~/hooks/useInterval";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type GenerationRow, PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { SpanView } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
import { requireUserId } from "~/services/session.server";
import { rbac } from "~/services/rbac.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
import { PromptService } from "~/v3/services/promptService.server";
import { z } from "zod";
import { AIChatIcon } from "~/assets/icons/AIChatIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { InlineCode } from "~/components/code/InlineCode";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { MetricWidget } from "~/routes/resources.metric";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema, v3PromptsPath, v3RunSpanPath } from "~/utils/pathBuilder";
import { parsePeriodToMs } from "~/utils/periods";
const ParamSchema = EnvironmentParamSchema.extend({
promptSlug: z.string(),
});
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${(data as any)?.prompt.slug ?? "Prompt"} | Trigger.dev` }];
};
// ─── Action ──────────────────────────────────────────────
const ActionSchema = z.discriminatedUnion("intent", [
z.object({
intent: z.literal("promote"),
versionId: z.string(),
}),
z.object({
intent: z.literal("saveVersion"),
textContent: z.string().optional(),
model: z.string().optional(),
commitMessage: z.string().optional(),
}),
z.object({
intent: z.literal("updateOverride"),
textContent: z.string().optional(),
model: z.string().optional(),
commitMessage: z.string().optional(),
}),
z.object({
intent: z.literal("removeOverride"),
}),
z.object({
intent: z.literal("reactivateOverride"),
versionId: z.string(),
}),
]);
export const action = dashboardAction(
{
params: ParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
},
async ({ request, params, user, ability, context }) => {
const { organizationSlug, projectParam, envParam, promptSlug } = params;
// This action checks permissions per intent inline (below) rather than via
// a top-level authorization block, so the builder's fail-closed scope guard
// doesn't run. Enforce it here: without a resolved org the inline
// ability.can checks would evaluate an unscoped ability.
if (!context.organizationId) {
return json({ error: "Unauthorized" }, { status: 403 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) return json({ error: "Project not found" }, { status: 404 });
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) return json({ error: "Environment not found" }, { status: 404 });
const formData = Object.fromEntries(await request.formData());
const parsed = ActionSchema.safeParse(formData);
if (!parsed.success) return json({ error: "Invalid action" }, { status: 400 });
const prompt = await prisma.prompt.findUnique({
where: {
projectId_runtimeEnvironmentId_slug: {
projectId: project.id,
runtimeEnvironmentId: environment.id,
slug: promptSlug,
},
},
});
if (!prompt) return json({ error: "Prompt not found" }, { status: 404 });
const data = parsed.data;
// Promoting a version to production is `update:prompts`; creating or
// editing override versions is `write:prompts`. Check the right one per
// intent — a single authorization block can't express both.
const requiredAction = data.intent === "promote" ? "update" : "write";
if (!ability.can(requiredAction, { type: "prompts" })) {
return json({ error: "Unauthorized" }, { status: 403 });
}
const service = new PromptService();
if (data.intent === "promote") {
await service.promoteVersion(prompt.id, data.versionId);
return json({ ok: true });
}
const url = new URL(request.url);
if (data.intent === "saveVersion") {
const result = await service.createOverride(prompt.id, {
textContent: data.textContent ?? "",
model: data.model,
commitMessage: data.commitMessage,
source: "dashboard",
createdBy: user.id,
});
url.searchParams.set("version", String(result.version));
return redirect(url.pathname + url.search);
}
if (data.intent === "updateOverride") {
await service.updateOverride(prompt.id, {
textContent: data.textContent,
model: data.model,
commitMessage: data.commitMessage,
});
return json({ ok: true });
}
if (data.intent === "removeOverride") {
await service.removeOverride(prompt.id);
// Navigate back to current version
const currentVersion = await prisma.promptVersion.findFirst({
where: { promptId: prompt.id, labels: { has: "current" } },
select: { version: true },
});
if (currentVersion) {
url.searchParams.set("version", String(currentVersion.version));
} else {
url.searchParams.delete("version");
}
return redirect(url.pathname + url.search);
}
if (data.intent === "reactivateOverride") {
await service.reactivateOverride(prompt.id, data.versionId);
return json({ ok: true });
}
return json({ error: "Unknown intent" }, { status: 400 });
}
);
// ─── Loader ──────────────────────────────────────────────
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, promptSlug } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) throw new Response("Project not found", { status: 404 });
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) throw new Response("Environment not found", { status: 404 });
const prompt = await prisma.prompt.findUnique({
where: {
projectId_runtimeEnvironmentId_slug: {
projectId: project.id,
runtimeEnvironmentId: environment.id,
slug: promptSlug,
},
},
include: {
versions: { orderBy: { version: "desc" }, take: 50 },
},
});
if (!prompt) throw new Response("Prompt not found", { status: 404 });
const currentVersion = prompt.versions.find((v) => v.labels.includes("current"));
const overrideVersion = prompt.versions.find((v) => v.labels.includes("override"));
// Query ClickHouse for recent generations (for the Generations tab span list)
const url = new URL(request.url);
const period = url.searchParams.get("period") ?? "7d";
const periodMs = parsePeriodToMs(period);
const fromTime = url.searchParams.get("from");
const toTime = url.searchParams.get("to");
const startTime = fromTime ? new Date(fromTime) : new Date(Date.now() - periodMs);
const endTime = toTime ? new Date(toTime) : new Date();
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new PromptPresenter(clickhouse);
let generations: Awaited<ReturnType<typeof presenter.listGenerations>>["generations"] = [];
let generationsPagination: { next?: string } = {};
try {
const urlVersions = url.searchParams
.getAll("versions")
.filter(Boolean)
.map(Number)
.filter((n) => !isNaN(n));
const urlModels = url.searchParams.getAll("models").filter(Boolean);
const urlOperations = url.searchParams.getAll("operations").filter(Boolean);
const urlProviders = url.searchParams.getAll("providers").filter(Boolean);
const result = await presenter.listGenerations({
environmentId: environment.id,
promptSlug: prompt.slug,
promptVersions: urlVersions.length > 0 ? urlVersions : undefined,
startTime,
endTime,
responseModels: urlModels.length > 0 ? urlModels : undefined,
operations: urlOperations.length > 0 ? urlOperations : undefined,
providers: urlProviders.length > 0 ? urlProviders : undefined,
});
generations = result.generations;
generationsPagination = result.pagination;
} catch (e) {
console.error("Prompt generations query exception:", e);
}
// Load distinct filter values and resizable snapshots in parallel
const distinctQuery = (col: string, name: string) =>
clickhouse.reader.query({
name,
query: `SELECT DISTINCT ${col} AS val FROM trigger_dev.llm_metrics_v1 WHERE environment_id = {environmentId: String} AND prompt_slug = {promptSlug: String} AND ${col} != '' ORDER BY val`,
params: z.object({ environmentId: z.string(), promptSlug: z.string() }),
schema: z.object({ val: z.string() }),
})({ environmentId: environment.id, promptSlug: prompt.slug });
const [
resizableOuter,
resizableVertical,
resizableGenerations,
[modelsErr, modelsRows],
[opsErr, opsRows],
[provsErr, provsRows],
] = await Promise.all([
getResizableSnapshot(request, "prompt-detail"),
getResizableSnapshot(request, "prompt-vertical"),
getResizableSnapshot(request, "prompt-generations"),
distinctQuery("response_model", "promptDistinctModels"),
distinctQuery("operation_id", "promptDistinctOperations"),
distinctQuery("gen_ai_system", "promptDistinctProviders"),
]);
const possibleModels = modelsErr ? [] : modelsRows.map((r) => r.val);
const possibleOperations = opsErr ? [] : opsRows.map((r) => r.val);
const possibleProviders = provsErr ? [] : provsRows.map((r) => r.val);
// Display flags for the promote / override controls — the action enforces
// update:prompts and write:prompts independently. Permissive in OSS.
const promptAuth = await rbac.authenticateSession(request, {
userId,
organizationId: project.organizationId,
});
const promptPermissions = promptAuth.ok
? checkPermissions(promptAuth.ability, {
canWritePrompts: { action: "write", resource: { type: "prompts" } },
canPromote: { action: "update", resource: { type: "prompts" } },
})
: { canWritePrompts: true, canPromote: true };
return typedjson({
resizable: {
outer: resizableOuter,
vertical: resizableVertical,
generations: resizableGenerations,
},
prompt: {
id: prompt.id,
friendlyId: prompt.friendlyId,
slug: prompt.slug,
description: prompt.description,
tags: prompt.tags,
defaultModel: prompt.defaultModel,
defaultConfig: prompt.defaultConfig,
variableSchema: prompt.variableSchema,
filePath: prompt.filePath,
exportName: prompt.exportName,
},
currentVersion: currentVersion
? {
id: currentVersion.id,
version: currentVersion.version,
textContent: currentVersion.textContent,
model: currentVersion.model,
source: currentVersion.source,
labels: currentVersion.labels,
}
: null,
versions: prompt.versions.map((v) => ({
id: v.id,
version: v.version,
labels: v.labels,
source: v.source,
commitMessage: v.commitMessage,
textContent: v.textContent,
model: v.model,
createdAt: v.createdAt,
})),
overrideVersion: overrideVersion
? {
id: overrideVersion.id,
version: overrideVersion.version,
}
: null,
generations,
generationsPagination,
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
possibleModels,
possibleOperations,
possibleProviders,
...promptPermissions,
});
};
// ─── Helpers ─────────────────────────────────────────────
type VersionData = {
id: string;
version: number;
labels: string[];
source: string;
commitMessage: string | null;
textContent: string | null;
model: string | null;
createdAt: Date;
};
type VariableField = {
name: string;
type: string;
required: boolean;
description?: string;
placeholder?: string;
enumValues?: string[];
isLongText: boolean;
};
function extractVariableFields(schema: unknown): VariableField[] {
const jsonSchema = schema as {
properties?: Record<string, { type?: string; description?: string; enum?: string[] }>;
required?: string[];
} | null;
if (!jsonSchema?.properties) return [];
const requiredSet = new Set(jsonSchema.required ?? []);
return Object.entries(jsonSchema.properties).map(([name, prop]) => ({
name,
type: prop.type ?? "string",
required: requiredSet.has(name),
description: prop.description,
placeholder: prop.description ?? name,
enumValues: prop.enum,
isLongText:
prop.type === "string" &&
!prop.enum &&
(name.toLowerCase().includes("text") ||
name.toLowerCase().includes("content") ||
name.toLowerCase().includes("message") ||
name.toLowerCase().includes("body")),
}));
}
function compileTemplatePreview(template: string, variables: Record<string, string>): string {
let result = template.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_match, key, content) => {
const value = variables[key];
return value
? content.replace(/\{\{(\w+)\}\}/g, (_m: string, k: string) => variables[k] ?? "")
: "";
});
result = result.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => {
return variables[key] ?? `{{${key}}}`;
});
return result;
}
function getVersionContent(version: { textContent?: string | null }): string {
return version.textContent ?? "";
}
// ─── Component ───────────────────────────────────────────
export default function PromptDetailPage() {
const {
prompt,
currentVersion,
versions,
overrideVersion,
generations,
generationsPagination,
organizationId,
projectId,
environmentId,
resizable,
possibleModels,
possibleOperations,
possibleProviders,
canWritePrompts,
canPromote,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useFetcher();
const { value: searchValue, replace: replaceSearch } = useSearchParams();
const tab = searchValue("tab") ?? "details";
const contentTab = searchValue("contentTab") ?? "generations";
const period = searchValue("period") ?? "7d";
const from = searchValue("from") ?? null;
const to = searchValue("to") ?? null;
// Selected span for the generations inspector — auto-select first generation
const [selectedSpan, setSelectedSpan] = useState<{ runId: string; spanId: string } | null>(
generations[0] ? { runId: generations[0].run_id, spanId: generations[0].span_id } : null
);
// Selected version from URL or default to current
const versionParam = searchValue("version");
const selectedVersion = versionParam
? (versions.find((v) => v.version === Number(versionParam)) ?? versions[0])
: overrideVersion
? (versions.find((v) => v.id === overrideVersion.id) ?? versions[0])
: currentVersion
? (versions.find((v) => v.id === currentVersion.id) ?? versions[0])
: versions[0];
const content = selectedVersion ? getVersionContent(selectedVersion) : "";
const isCurrent = selectedVersion?.labels.includes("current") ?? false;
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
const handlePromote = (versionId: string) => {
fetcher.submit({ intent: "promote", versionId }, { method: "POST" });
};
const switchVersion = (versionNumber: number) => {
replaceSearch({
version: String(versionNumber),
tab,
});
};
return (
<PageContainer className="grid-rows-[auto_auto_1fr]">
<NavBar>
<PageTitle
title={
<PromptCopyPopover
slug={prompt.slug}
friendlyId={prompt.friendlyId}
description={prompt.description}
/>
}
backButton={{ to: v3PromptsPath(organization, project, environment), text: "Prompts" }}
/>
<PageAccessories>
<div className="flex items-center gap-2">
{selectedVersion && (
<div className="flex items-center gap-1.5">
<div
className={cn(
"size-1.5 shrink-0 rounded-full",
selectedVersion.labels.includes("override")
? "bg-amber-400"
: isCurrent
? "bg-green-500"
: "bg-surface-control-hover"
)}
/>
<span className="text-xs text-text-dimmed">v{selectedVersion.version}</span>
{isCurrent && <Badge variant="extra-small">current</Badge>}
{selectedVersion.labels.includes("override") && (
<Badge variant="extra-small" className="border-amber-500/30 text-amber-400">
override
</Badge>
)}
</div>
)}
{selectedVersion && !isCurrent && selectedVersion.source === "code" && (
<PermissionButton
hasPermission={canPromote}
noPermissionTooltip="You don't have permission to promote prompt versions"
variant="secondary/small"
onClick={() => handlePromote(selectedVersion.id)}
disabled={fetcher.state !== "idle"}
>
Promote to current
</PermissionButton>
)}
{selectedVersion &&
selectedVersion.source !== "code" &&
!selectedVersion.labels.includes("override") && (
<PermissionButton
hasPermission={canWritePrompts}
noPermissionTooltip="You don't have permission to edit prompt overrides"
variant="secondary/small"
onClick={() =>
fetcher.submit(
{ intent: "reactivateOverride", versionId: selectedVersion.id },
{ method: "POST" }
)
}
disabled={fetcher.state !== "idle"}
>
Reactivate as override
</PermissionButton>
)}
{!overrideVersion && (
<PermissionButton
hasPermission={canWritePrompts}
noPermissionTooltip="You don't have permission to edit prompt overrides"
variant="secondary/small"
onClick={() => setOverrideDialogOpen(true)}
>
Create override
</PermissionButton>
)}
</div>
</PageAccessories>
</NavBar>
<div>
<AnimatePresence initial={false}>
{overrideVersion && (
<motion.div
className="flex flex-wrap items-center justify-between gap-2 overflow-hidden border-b border-amber-500/10 bg-amber-500/10 pl-4 pr-2"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<span className="py-1.5 text-xs text-amber-300">
Override v{overrideVersion.version} is active. API calls resolve to this version
instead of the deployed prompt.
</span>
<div className="flex items-center gap-2 py-1.5">
<PermissionButton
hasPermission={canWritePrompts}
noPermissionTooltip="You don't have permission to edit prompt overrides"
variant="tertiary/small"
className="border-amber-300/50 bg-amber-400/10 text-amber-300 group-hover/button:border-amber-400/60 group-hover/button:bg-amber-500/25 group-hover/button:text-amber-200"
onClick={() => setOverrideDialogOpen(true)}
>
Edit
</PermissionButton>
<PermissionButton
hasPermission={canWritePrompts}
noPermissionTooltip="You don't have permission to edit prompt overrides"
variant="tertiary/small"
className="border-amber-300/50 bg-amber-400/10 text-amber-300 group-hover/button:border-amber-400/60 group-hover/button:bg-amber-500/25 group-hover/button:text-amber-200"
onClick={() => fetcher.submit({ intent: "removeOverride" }, { method: "POST" })}
disabled={fetcher.state !== "idle"}
>
Remove
</PermissionButton>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<PageBody scrollable={false}>
<ResizablePanelGroup
autosaveId="prompt-detail"
snapshot={resizable?.outer}
className="h-full max-h-full"
>
{/* Main content */}
<ResizablePanel id="prompt-main" min="400px">
<ResizablePanelGroup
autosaveId="prompt-vertical"
snapshot={resizable?.vertical}
orientation="vertical"
className="h-full max-h-full"
>
{/* Template panel */}
<ResizablePanel id="prompt-template" default="250px" min="80px">
{content ? (
<CodeBlock
code={content}
language="markdown"
showLineNumbers={false}
showCopyButton={true}
showTextWrapping={false}
showOpenInModal={true}
className="h-full rounded-none border-none [&_pre]:px-3 [&_pre]:py-2 [&_pre]:text-sm"
maxLines={undefined}
/>
) : (
<div className="p-3">
<Paragraph variant="small" className="text-text-dimmed">
No content
</Paragraph>
</div>
)}
</ResizablePanel>
<ResizableHandle id="prompt-vertical-handle" />
{/* Tabs panel */}
<ResizablePanel id="prompt-tabs" min="100px">
<div className="grid h-full max-h-full grid-rows-[2.25rem_1fr] overflow-hidden">
{/* Tab bar */}
<div className="flex items-center justify-between border-b border-grid-dimmed bg-background-bright pl-3 pr-1.5">
<TabContainer className="-mb-1">
<TabButton
isActive={contentTab === "generations"}
layoutId="prompt-content"
onClick={() =>
replaceSearch({
contentTab: "generations",
tab,
version: searchValue("version"),
period: searchValue("period"),
from: searchValue("from"),
to: searchValue("to"),
})
}
>
Generations
</TabButton>
<TabButton
isActive={contentTab === "metrics"}
layoutId="prompt-content"
onClick={() =>
replaceSearch({
contentTab: "metrics",
tab,
version: searchValue("version"),
period: searchValue("period"),
from: searchValue("from"),
to: searchValue("to"),
})
}
>
Metrics
</TabButton>
</TabContainer>
<div className="flex items-center gap-1.5">
<PromptVersionsFilter versions={versions} />
<ModelsFilter
possibleModels={possibleModels.map((m) => ({ model: m, system: "" }))}
/>
<OperationsFilter possibleOperations={possibleOperations} />
<ProvidersFilter possibleProviders={possibleProviders} />
<TimeFilter
defaultPeriod="7d"
labelName="Period"
hideLabel
valueClassName="text-text-bright"
shortcut={{ key: "w" }}
/>
</div>
</div>
{/* Tab content */}
<div
className={cn(
"min-h-0 overflow-hidden",
contentTab === "generations" && "bg-background-bright"
)}
>
{contentTab === "generations" && (
<GenerationsTab
promptSlug={prompt.slug}
initialGenerations={generations}
initialPagination={generationsPagination}
selectedSpan={selectedSpan}
onSelectSpan={setSelectedSpan}
generationsSnapshot={resizable?.generations}
/>
)}
{contentTab === "metrics" && (
<div className="h-full overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<MetricsTab
prompt={prompt}
organizationId={organizationId}
projectId={projectId}
environmentId={environmentId}
period={period}
from={from}
to={to}
/>
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle id="prompt-sidebar-handle" />
{/* Sidebar */}
<ResizablePanel
id="prompt-sidebar"
default="380px"
min="280px"
max="500px"
isStaticAtRest
>
<div className="grid h-full max-h-full grid-rows-[2rem_1fr] overflow-hidden bg-background-bright">
{/* Tabs */}
<div className="overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TabContainer>
<TabButton
isActive={tab === "details"}
layoutId="prompt-sidebar"
onClick={() => replaceSearch({ tab: "details", version: versionParam })}
shortcut={{ key: "d" }}
>
Details
</TabButton>
<TabButton
isActive={tab === "preview"}
layoutId="prompt-sidebar"
onClick={() => replaceSearch({ tab: "preview", version: versionParam })}
shortcut={{ key: "i" }}
>
Preview
</TabButton>
<TabButton
isActive={tab === "versions"}
layoutId="prompt-sidebar"
onClick={() => replaceSearch({ tab: "versions", version: versionParam })}
shortcut={{ key: "v" }}
>
Versions
</TabButton>
</TabContainer>
</div>
{/* Tab content */}
<div
className={cn(
"overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
tab === "versions" ? "py-0" : "px-3 py-3"
)}
>
{tab === "details" && (
<DetailsTab prompt={prompt} selectedVersion={selectedVersion} />
)}
{tab === "preview" && <PreviewTab prompt={prompt} content={content} />}
{tab === "versions" && (
<VersionsTab
versions={versions}
selectedVersion={selectedVersion}
onSelectVersion={switchVersion}
/>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</PageBody>
<OverrideDialog
open={overrideDialogOpen}
onOpenChange={setOverrideDialogOpen}
prompt={prompt}
content={
overrideVersion
? getVersionContent(
versions.find((v) => v.id === overrideVersion.id) ?? { textContent: null }
)
: content
}
isEditingOverride={!!overrideVersion}
currentOverrideModel={
overrideVersion
? (versions.find((v) => v.id === overrideVersion.id)?.model ?? null)
: null
}
onSave={(textContent, commitMessage, model) => {
const intent = overrideVersion ? "updateOverride" : "saveVersion";
fetcher.submit({ intent, textContent, commitMessage, model }, { method: "POST" });
setOverrideDialogOpen(false);
}}
/>
</PageContainer>
);
}
// ─── Override Dialog ─────────────────────────────────────
function OverrideDialog({
open,
onOpenChange,
prompt,
content,
isEditingOverride,
currentOverrideModel,
onSave,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
prompt: {
slug: string;
defaultModel: string | null;
defaultConfig: unknown;
variableSchema: unknown;
};
content: string;
isEditingOverride: boolean;
currentOverrideModel?: string | null;
onSave: (textContent: string, commitMessage: string, model: string) => void;
}) {
const effectiveModel = currentOverrideModel ?? prompt.defaultModel ?? "";
const [editedContent, setEditedContent] = useState(content);
const [commitMessage, setCommitMessage] = useState("");
const [model, setModel] = useState(effectiveModel);
// Reset when dialog opens
useEffect(() => {
if (open) {
setEditedContent(content);
setCommitMessage("");
setModel(currentOverrideModel ?? prompt.defaultModel ?? "");
}
}, [open, content, currentOverrideModel, prompt.defaultModel]);
const variableFields = prompt.variableSchema ? extractVariableFields(prompt.variableSchema) : [];
// Extract variables used in the template
const templateVars = new Set<string>();
editedContent.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, key) => {
templateVars.add(key);
return "";
});
editedContent.replace(/\{\{#(\w+)\}\}/g, (_m, key) => {
templateVars.add(key);
return "";
});
// Check for schema mismatches
const schemaVarNames = new Set(variableFields.map((f) => f.name));
const undefinedVars = [...templateVars].filter((v) => !schemaVarNames.has(v));
const unusedVars = variableFields.filter((f) => f.required && !templateVars.has(f.name));
const hasChanges = editedContent !== content || model !== effectiveModel;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[85vh] max-h-[85vh] flex-col gap-0! overflow-hidden pl-0 pr-3 pt-0 md:max-w-4xl lg:max-w-6xl">
<DialogHeader className="px-4 py-2.5">
{isEditingOverride ? "Edit override" : "Create override"}
</DialogHeader>
<ResizablePanelGroup
orientation="horizontal"
className="-mx-3 w-auto flex-1 border-b border-t border-grid-dimmed"
>
{/* Editor */}
<ResizablePanel id="override-editor" min="300px" className="bg-[#121317]">
<TextEditor
className="h-full"
autoFocus
defaultValue={editedContent}
onChange={setEditedContent}
showCopyButton
/>
</ResizablePanel>
<ResizableHandle id="override-handle" />
{/* Right panel: properties */}
<ResizablePanel id="override-sidebar" min="220px" default="280px" max="360px">
<div className="h-full overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="space-y-4">
<div className="space-y-2">
<Header3>Override settings</Header3>
<InputGroup>
<Label variant="small">Commit message</Label>
<Input
variant="small"
placeholder="What changed? (optional)"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
/>
</InputGroup>
<InputGroup>
<Label variant="small">Model</Label>
<Input
variant="small"
placeholder={prompt.defaultModel ?? "No default"}
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<Hint>Override the default model for this version</Hint>
</InputGroup>
</div>
{/* Variable schema (read-only) */}
{variableFields.length > 0 && (
<div className="space-y-1.5 border-t border-grid-dimmed pt-3">
<Label variant="small">Variables (from code)</Label>
<div className="space-y-1">
{variableFields.map((f) => (
<div key={f.name} className="flex items-center gap-1.5 text-xs">
<code className="rounded bg-background-hover px-1 py-0.5 text-text-bright">
{f.name}
</code>
<span className="text-text-dimmed">{f.type}</span>
{f.required && (
<Badge
variant="extra-small"
className="border-amber-500/30 text-amber-400"
>
required
</Badge>
)}
</div>
))}
</div>
</div>
)}
{/* Validation warnings */}
{(undefinedVars.length > 0 || unusedVars.length > 0) && (
<div className="space-y-2 border-t border-grid-dimmed pt-3">
{undefinedVars.length > 0 && (
<div className="rounded border border-amber-500/30 bg-amber-500/5 px-2.5 py-2 text-xs text-amber-300">
<span className="font-medium">Undefined variables:</span>{" "}
{undefinedVars.map((v) => (
<code
key={v}
className="mx-0.5 rounded bg-amber-500/10 px-1"
>{`{{${v}}}`}</code>
))}
</div>
)}
{unusedVars.length > 0 && (
<div className="rounded border border-amber-500/30 bg-amber-500/5 px-2.5 py-2 text-xs text-amber-300">
<span className="font-medium">Required but missing:</span>{" "}
{unusedVars.map((v) => (
<code key={v.name} className="mx-0.5 rounded bg-amber-500/10 px-1">
{v.name}
</code>
))}
</div>
)}
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* Footer */}
<div className="flex items-center justify-between gap-2 pl-4 pr-1 pt-3">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<Button
variant="primary/medium"
disabled={!hasChanges}
onClick={() => onSave(editedContent, commitMessage, model)}
>
{isEditingOverride ? "Save override" : "Create override"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Details Tab ─────────────────────────────────────────
function DetailsTab({
prompt,
selectedVersion,
}: {
prompt: ReturnType<typeof useTypedLoaderData<typeof loader>>["prompt"];
selectedVersion?: VersionData;
}) {
const effectiveModel = selectedVersion?.model ?? prompt.defaultModel;
return (
<div className="space-y-4">
<Property.Table>
<Property.Item>
<Property.Label>Slug</Property.Label>
<Property.Value>
<code className="text-sm">{prompt.slug}</code>
</Property.Value>
</Property.Item>
{prompt.description && (
<Property.Item>
<Property.Label>Description</Property.Label>
<Property.Value>{prompt.description}</Property.Value>
</Property.Item>
)}
{effectiveModel && (
<Property.Item>
<Property.Label>Model</Property.Label>
<Property.Value>{effectiveModel}</Property.Value>
</Property.Item>
)}
{prompt.defaultConfig && (
<Property.Item>
<Property.Label className="mb-1.5">Config</Property.Label>
<Property.Value>
<CodeBlock
code={JSON.stringify(prompt.defaultConfig, null, 2)}
maxLines={10}
showLineNumbers={false}
/>
</Property.Value>
</Property.Item>
)}
{prompt.filePath && (
<Property.Item>
<Property.Label>Source</Property.Label>
<Property.Value>
<code className="text-sm">
{prompt.filePath}
{prompt.exportName ? ` (${prompt.exportName})` : ""}
</code>
</Property.Value>
</Property.Item>
)}
{prompt.tags.length > 0 && (
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
<div className="flex flex-wrap gap-1">
{prompt.tags.map((tag) => (
<Badge key={tag} variant="extra-small">
{tag}
</Badge>
))}
</div>
</Property.Value>
</Property.Item>
)}
</Property.Table>
{prompt.variableSchema && (
<div className="space-y-2">
<Header3>Variable schema</Header3>
<CodeBlock
code={JSON.stringify(prompt.variableSchema, null, 2)}
maxLines={20}
showLineNumbers={false}
showCopyButton
/>
</div>
)}
</div>
);
}
// ─── Preview Tab ─────────────────────────────────────────
function PreviewTab({
prompt,
content,
}: {
prompt: ReturnType<typeof useTypedLoaderData<typeof loader>>["prompt"];
content: string;
}) {
const variableFields = prompt.variableSchema ? extractVariableFields(prompt.variableSchema) : [];
const [testVariables, setTestVariables] = useState<Record<string, string>>(() =>
Object.fromEntries(variableFields.map((f) => [f.name, ""]))
);
const hasTestValues = Object.values(testVariables).some((v) => v.length > 0);
const previewText = hasTestValues ? compileTemplatePreview(content, testVariables) : null;
return (
<div className="space-y-4">
{variableFields.length > 0 ? (
<>
<div className="space-y-3">
<div>
<Header3 className="mb-1">Variables</Header3>
<Paragraph variant="small">
Fill in values to preview your resolved prompt template.
</Paragraph>
</div>
{variableFields.map((field, index) => (
<InputGroup className="max-w-full" key={field.name}>
<Label variant="small" required={field.required}>
{field.name}
</Label>
{field.enumValues ? (
<select
autoFocus={index === 0}
className="h-6 w-full rounded border border-border-bright bg-background-bright px-1 text-xs text-text-bright focus:border-indigo-500 focus:outline-hidden"
value={testVariables[field.name] ?? ""}
onChange={(e) =>
setTestVariables((prev) => ({
...prev,
[field.name]: e.target.value,
}))
}
>
<option value="">Select</option>
{field.enumValues.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
) : field.isLongText ? (
<TextArea
autoFocus={index === 0}
rows={3}
className="w-full text-sm"
placeholder={field.placeholder}
value={testVariables[field.name] ?? ""}
onChange={(e) =>
setTestVariables((prev) => ({
...prev,
[field.name]: e.target.value,
}))
}
/>
) : (
<Input
autoFocus={index === 0}
variant="small"
placeholder={field.placeholder}
value={testVariables[field.name] ?? ""}
onChange={(e) =>
setTestVariables((prev) => ({
...prev,
[field.name]: e.target.value,
}))
}
/>
)}
{field.description && <Hint>{field.description}</Hint>}
</InputGroup>
))}
</div>
{previewText && (
<div className="space-y-1.5">
<Header3>Resolved output</Header3>
<div className="overflow-auto rounded border border-grid-bright bg-background-dimmed p-3">
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-text-bright">
{previewText}
</pre>
</div>
</div>
)}
</>
) : (
content && (
<>
<Header3>Resolved output</Header3>
<div className="overflow-auto rounded border border-grid-bright bg-background-dimmed p-3">
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-text-bright">
{content}
</pre>
</div>
</>
)
)}
</div>
);
}
// ─── Generations Tab ─────────────────────────────────────
function GenerationsTab({
promptSlug,
initialGenerations,
initialPagination,
selectedSpan,
onSelectSpan,
generationsSnapshot,
}: {
promptSlug: string;
initialGenerations: GenerationRow[];
initialPagination: { next?: string };
selectedSpan: { runId: string; spanId: string } | null;
onSelectSpan: (span: { runId: string; spanId: string } | null) => void;
generationsSnapshot?: ResizableSnapshot;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { value: searchValue } = useSearchParams();
const listRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const fetcher = useFetcher<{ generations: GenerationRow[]; pagination: { next?: string } }>();
const pollFetcher = useFetcher<{ generations: GenerationRow[]; pagination: { next?: string } }>();
// Accumulated generations state
const [generations, setGenerations] = useState<GenerationRow[]>(initialGenerations);
const [nextCursor, setNextCursor] = useState<string | undefined>(initialPagination.next);
const [newGenerationCount, setNewGenerationCount] = useState(0);
// Build the resource URL for fetches
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
environment.slug
}/prompts/${encodeURIComponent(promptSlug)}/generations`;
// Track filter state to reset on change
const { values: filterValues } = useSearchParams();
const models = filterValues("models").filter((v) => v !== "");
const operations = filterValues("operations").filter((v) => v !== "");
const providers = filterValues("providers").filter((v) => v !== "");
const versionFilters = filterValues("versions").filter((v) => v !== "");
const filterKey = `${versionFilters.join(",")}-${searchValue("period") ?? "7d"}-${
searchValue("from") ?? ""
}-${searchValue("to") ?? ""}-${models.join(",")}-${operations.join(",")}-${providers.join(",")}`;
const prevFilterKeyRef = useRef(filterKey);
// Reset when filters change (loader re-runs with new initial data)
useEffect(() => {
if (prevFilterKeyRef.current !== filterKey) {
prevFilterKeyRef.current = filterKey;
setGenerations(initialGenerations);
setNextCursor(initialPagination.next);
setNewGenerationCount(0);
}
}, [filterKey, initialGenerations, initialPagination.next]);
// Also reset if initialGenerations change identity (page navigation)
const initialGenRef = useRef(initialGenerations);
useEffect(() => {
if (initialGenRef.current !== initialGenerations) {
initialGenRef.current = initialGenerations;
setGenerations(initialGenerations);
setNextCursor(initialPagination.next);
setNewGenerationCount(0);
}
}, [initialGenerations, initialPagination.next]);
// Append fetched rows when fetcher completes
useEffect(() => {
if (fetcher.data && fetcher.state === "idle") {
setGenerations((prev) => {
const existingIds = new Set(prev.map((g) => g.span_id));
const newRows = fetcher.data!.generations.filter((g) => !existingIds.has(g.span_id));
return newRows.length > 0 ? [...prev, ...newRows] : prev;
});
setNextCursor(fetcher.data.pagination.next);
}
}, [fetcher.data, fetcher.state]);
// Poll for new generations periodically (also on focus/visibility)
useInterval({
interval: 10_000,
onLoad: false,
callback: () => {
if (pollFetcher.state !== "idle") return;
const params = new URLSearchParams();
for (const v of versionFilters) params.append("versions", v);
params.set("period", searchValue("period") ?? "7d");
const from = searchValue("from");
const to = searchValue("to");
if (from) params.set("from", from);
if (to) params.set("to", to);
for (const m of models) params.append("models", m);
for (const o of operations) params.append("operations", o);
for (const p of providers) params.append("providers", p);
pollFetcher.load(`${resourcePath}?${params.toString()}`);
},
});
// Check poll results for new generations — only react to new poll data, not generation list changes
const lastPollDataRef = useRef(pollFetcher.data);
useEffect(() => {
if (
pollFetcher.data &&
pollFetcher.state === "idle" &&
pollFetcher.data !== lastPollDataRef.current
) {
lastPollDataRef.current = pollFetcher.data;
const existingIds = new Set(generations.map((g) => g.span_id));
const newCount = pollFetcher.data.generations.filter(
(g) => !existingIds.has(g.span_id)
).length;
setNewGenerationCount(newCount);
}
}, [pollFetcher.data, pollFetcher.state, generations]);
const handleRefreshGenerations = useCallback(() => {
// Prepend new generations from the poll data
if (pollFetcher.data) {
setGenerations((prev) => {
const existingIds = new Set(prev.map((g) => g.span_id));
const newRows = pollFetcher.data!.generations.filter((g) => !existingIds.has(g.span_id));
return newRows.length > 0 ? [...newRows, ...prev] : prev;
});
}
setNewGenerationCount(0);
listRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [pollFetcher.data]);
const handleLoadMore = useCallback(() => {
if (!nextCursor || fetcher.state !== "idle") return;
const params = new URLSearchParams();
for (const v of versionFilters) params.append("versions", v);
params.set("period", searchValue("period") ?? "7d");
const from = searchValue("from");
const to = searchValue("to");
if (from) params.set("from", from);
if (to) params.set("to", to);
for (const m of models) params.append("models", m);
for (const o of operations) params.append("operations", o);
for (const p of providers) params.append("providers", p);
params.set("cursor", nextCursor);
fetcher.load(`${resourcePath}?${params.toString()}`);
}, [
nextCursor,
fetcher,
searchValue,
resourcePath,
versionFilters,
models,
operations,
providers,
]);
// Debounced loading spinner
const isLoadingMore = fetcher.state === "loading";
const [showSpinner, setShowSpinner] = useState(false);
useEffect(() => {
if (!isLoadingMore) {
setShowSpinner(false);
return;
}
const timer = setTimeout(() => setShowSpinner(true), 200);
return () => clearTimeout(timer);
}, [isLoadingMore]);
// IntersectionObserver for infinite scroll
useEffect(() => {
if (!nextCursor || isLoadingMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
handleLoadMore();
}
},
{ threshold: 0.1 }
);
const currentRef = loadMoreRef.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, [nextCursor, isLoadingMore, handleLoadMore]);
// Keyboard navigation
const selectedIndex = selectedSpan
? generations.findIndex((g) => g.span_id === selectedSpan.spanId)
: -1;
const selectByIndex = useCallback(
(index: number) => {
const gen = generations[index];
if (gen) {
onSelectSpan({ runId: gen.run_id, spanId: gen.span_id });
const items = listRef.current?.querySelectorAll("[data-generation-item]");
items?.[index]?.scrollIntoView({ block: "nearest" });
}
},
[generations, onSelectSpan]
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === "ArrowDown" || e.key === "j") {
e.preventDefault();
const next = selectedIndex < generations.length - 1 ? selectedIndex + 1 : 0;
selectByIndex(next);
} else if (e.key === "ArrowUp" || e.key === "k") {
e.preventDefault();
const prev = selectedIndex > 0 ? selectedIndex - 1 : generations.length - 1;
selectByIndex(prev);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedIndex, generations.length, selectByIndex]);
if (generations.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<InfoPanel
title="No generations yet"
icon={AIChatIcon}
iconClassName="text-aiPrompts"
panelClassName="max-w-md"
>
<Paragraph variant="small">
Generations appear here when this prompt version is resolved inside a task using{" "}
<InlineCode variant="small">prompt.resolve()</InlineCode> with an AI SDK call.
</Paragraph>
<Paragraph variant="small">
Try adjusting the time period filter or switching to a different version.
</Paragraph>
</InfoPanel>
</div>
);
}
return (
<ResizablePanelGroup
autosaveId="prompt-generations"
snapshot={generationsSnapshot}
className="h-full"
>
{/* Span list */}
<ResizablePanel id="prompt-gen-list" min="200px">
<div
ref={listRef}
className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
{newGenerationCount > 0 && (
<div className="sticky top-0 z-20 flex items-center justify-center gap-2 border-b border-grid-dimmed bg-background-bright px-3 py-1.5">
<span className="text-xs text-text-dimmed">
{newGenerationCount} new {newGenerationCount === 1 ? "generation" : "generations"}
</span>
<Button
variant="minimal/small"
onClick={handleRefreshGenerations}
LeadingIcon={ArrowPathIcon}
>
Refresh
</Button>
</div>
)}
<Table variant="bright" fullWidth showTopBorder={false}>
<TableHeader>
<TableRow>
<TableHeaderCell className="w-8" />
<TableHeaderCell>Operation</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Model</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
<TableHeaderCell>Cost</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell alignment="right">Time</TableHeaderCell>
<TableHeaderCell className="w-12" hiddenLabel>
Actions
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{generations.map((gen, i) => {
const isSelected = selectedSpan?.spanId === gen.span_id;
const runPath = v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: gen.run_id },
{ spanId: gen.span_id }
);
return (
<TableRow
key={`${gen.run_id}-${gen.span_id}-${i}`}
data-generation-item
isSelected={isSelected}
className="cursor-pointer [&_td]:cursor-pointer"
onClick={() => onSelectSpan({ runId: gen.run_id, spanId: gen.span_id })}
>
<TableCell>
<RadioButtonCircle checked={isSelected} />
</TableCell>
<TableCell className={cn("font-medium", isSelected && "text-text-bright")}>
{gen.operation_id || gen.task_identifier}
</TableCell>
<TableCell
className={cn(
"tabular-nums",
isSelected ? "text-text-bright" : "text-text-dimmed"
)}
>
v{gen.prompt_version}
</TableCell>
<TableCell className={cn(isSelected && "text-text-bright")}>
{gen.response_model}
</TableCell>
<TableCell className={cn("tabular-nums", isSelected && "text-text-bright")}>
{gen.input_tokens + gen.output_tokens}
</TableCell>
<TableCell className={cn("tabular-nums", isSelected && "text-text-bright")}>
{formatCost(gen.total_cost)}
</TableCell>
<TableCell className={cn("tabular-nums", isSelected && "text-text-bright")}>
{Math.round(gen.duration_ms)}ms
</TableCell>
<TableCell
alignment="right"
className={cn("tabular-nums", isSelected && "text-text-bright")}
>
{gen.start_time}
</TableCell>
<TableCellMenu
isSticky
isSelected={isSelected}
hiddenButtons={
<LinkButton
to={runPath}
onClick={(e) => e.stopPropagation()}
variant="minimal/small"
TrailingIcon={RunsIcon}
trailingIconClassName="text-text-bright"
className="h-5.5 pl-1.5 pr-2"
>
<span className="text-[0.6875rem] text-text-bright">View run</span>
</LinkButton>
}
/>
</TableRow>
);
})}
</TableBody>
</Table>
{/* Infinite scroll sentinel */}
<div ref={loadMoreRef} className="h-px" />
{showSpinner && (
<div className="flex items-center justify-center py-3">
<Spinner className="size-4" />
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle id="prompt-gen-handle" />
{/* Span inspector */}
<ResizablePanel id="prompt-gen-inspector" default="430px" min="320px" isStaticAtRest>
{selectedSpan ? (
<SpanView runParam={selectedSpan.runId} spanId={selectedSpan.spanId} />
) : (
<div className="flex h-full items-center justify-center bg-background-bright">
<Paragraph variant="small" className="text-text-dimmed">
Select a generation to view details
</Paragraph>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
);
}
function formatCost(cost: number): string {
if (cost >= 1) return `$${cost.toFixed(2)}`;
if (cost >= 0.01) return `$${cost.toFixed(4)}`;
if (cost === 0) return "$0";
return `$${cost.toFixed(6)}`;
}
// ─── Metrics Tab ─────────────────────────────────────────
function MetricsTab({
prompt,
organizationId,
projectId,
environmentId,
period,
from,
to,
}: {
prompt: { slug: string };
organizationId: string;
projectId: string;
environmentId: string;
period: string;
from: string | null;
to: string | null;
}) {
const { values: filterValues } = useSearchParams();
const versionFilters = filterValues("versions")
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0);
const models = filterValues("models").filter((v) => v !== "");
const operations = filterValues("operations").filter((v) => v !== "");
const providers = filterValues("providers").filter((v) => v !== "");
const widgetProps = {
organizationId,
projectId,
environmentId,
scope: "environment" as const,
period,
from,
to,
promptSlugs: [prompt.slug],
promptVersions: versionFilters.length > 0 ? versionFilters : undefined,
responseModels: models.length > 0 ? models : undefined,
operations: operations.length > 0 ? operations : undefined,
providers: providers.length > 0 ? providers : undefined,
};
return (
<div className="space-y-3">
{/* Summary big numbers */}
<div className="grid grid-cols-5 gap-3">
<div className="h-44">
<MetricWidget
widgetKey={`prompt-${prompt.slug}-generations`}
title="Total"
query={`SELECT count() AS generations FROM llm_metrics WHERE 1=1`}
config={{
type: "bignumber",
column: "generations",
aggregation: "sum",
abbreviate: true,
}}
{...widgetProps}
/>
</div>
<div className="h-44">
<MetricWidget
widgetKey={`prompt-${prompt.slug}-tokens`}
title="Avg input tokens"
query={`SELECT round(avg(input_tokens)) AS avg_input FROM llm_metrics WHERE 1=1`}
config={{
type: "bignumber",
column: "avg_input",
aggregation: "avg",
abbreviate: true,
}}
{...widgetProps}
/>
</div>
<div className="h-44">
<MetricWidget
widgetKey={`prompt-${prompt.slug}-cost`}
title="Avg input cost"
query={`SELECT avg(input_cost + cached_read_cost + cache_creation_cost) AS avg_cost FROM llm_metrics WHERE 1=1`}
config={{
type: "bignumber",
column: "avg_cost",
aggregation: "avg",
abbreviate: false,
}}
{...widgetProps}
/>
</div>
<div className="h-44">
<MetricWidget
widgetKey={`prompt-${prompt.slug}-latency`}
title="Avg latency"
query={`SELECT round(avg(duration) / 1000000, 1) AS avg_ms FROM llm_metrics WHERE 1=1`}
config={{
type: "bignumber",
column: "avg_ms",
aggregation: "avg",
abbreviate: false,
suffix: "ms",
}}
{...widgetProps}
/>
</div>
<div className="h-44">
<MetricWidget
widgetKey={`prompt-${prompt.slug}-cached-tokens`}
title="Cached tokens"
query={`SELECT sum(cached_read_tokens) AS cached_tokens FROM llm_metrics WHERE 1=1`}
config={{
type: "bignumber",
column: "cached_tokens",
aggregation: "sum",
abbreviate: true,
}}
{...widgetProps}
/>
</div>
</div>
{/* Version performance */}
<VersionPerformanceSection
promptSlug={prompt.slug}
organizationId={organizationId}
projectId={projectId}
environmentId={environmentId}
period={period}
from={from}
to={to}
/>
</div>
);
}
// ─── Version Performance Section ─────────────────────────
function VersionPerformanceSection({
promptSlug,
organizationId,
projectId,
environmentId,
period,
from,
to,
}: {
promptSlug: string;
organizationId: string;
projectId: string;
environmentId: string;
period: string;
from: string | null;
to: string | null;
}) {
const { values: filterValues } = useSearchParams();
const versionFilters = filterValues("versions")
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0);
const models = filterValues("models").filter((v) => v !== "");
const operations = filterValues("operations").filter((v) => v !== "");
const providers = filterValues("providers").filter((v) => v !== "");
const widgetProps = {
organizationId,
projectId,
environmentId,
scope: "environment" as const,
period,
from,
to,
promptSlugs: [promptSlug],
promptVersions: versionFilters.length > 0 ? versionFilters : undefined,
responseModels: models.length > 0 ? models : undefined,
operations: operations.length > 0 ? operations : undefined,
providers: providers.length > 0 ? providers : undefined,
};
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
{/* Row 1: Latency + TTFC */}
<div className="h-96">
<MetricWidget
widgetKey={`prompt-${promptSlug}-perf-latency`}
title="Latency p50 / p95"
query={`SELECT timeBucket(), round(quantile(0.5)(duration) / 1000000, 1) AS p50, round(quantile(0.95)(duration) / 1000000, 1) AS p95 FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
config={{
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["p50", "p95"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
}}
{...widgetProps}
/>
</div>
<div className="h-96">
<MetricWidget
widgetKey={`prompt-${promptSlug}-perf-ttfc`}
title="TTFC p50 / p95"
query={`SELECT timeBucket(), round(quantile(0.5)(ms_to_first_chunk), 1) AS p50, round(quantile(0.95)(ms_to_first_chunk), 1) AS p95 FROM llm_metrics WHERE ms_to_first_chunk > 0 GROUP BY timeBucket ORDER BY timeBucket`}
config={{
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["p50", "p95"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
}}
{...widgetProps}
/>
</div>
{/* Row 2: Input tokens + Input cost */}
<div className="h-96">
<MetricWidget
widgetKey={`prompt-${promptSlug}-perf-input-tokens`}
title="Input tokens p50 / p95"
query={`SELECT timeBucket(), round(quantile(0.5)(input_tokens)) AS p50, round(quantile(0.95)(input_tokens)) AS p95 FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
config={{
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["p50", "p95"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
}}
{...widgetProps}
/>
</div>
<div className="h-96">
<MetricWidget
widgetKey={`prompt-${promptSlug}-perf-input-cost`}
title="Input cost per 1k tokens (p50 / p95)"
query={`SELECT timeBucket(), prettyFormat(quantile(0.5)((input_cost + cached_read_cost + cache_creation_cost) / input_tokens * 1000), 'costInDollars') AS p50, prettyFormat(quantile(0.95)((input_cost + cached_read_cost + cache_creation_cost) / input_tokens * 1000), 'costInDollars') AS p95 FROM llm_metrics WHERE input_tokens > 0 GROUP BY timeBucket ORDER BY timeBucket`}
config={{
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["p50", "p95"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
}}
{...widgetProps}
/>
</div>
{/* Row 3: Output tokens + Output cost */}
<div className="h-96">
<MetricWidget
widgetKey={`prompt-${promptSlug}-perf-output-tokens`}
title="Output tokens p50 / p95"
query={`SELECT timeBucket(), round(quantile(0.5)(output_tokens)) AS p50, round(quantile(0.95)(output_tokens)) AS p95 FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
config={{
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["p50", "p95"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
}}
{...widgetProps}
/>
</div>
<div className="h-96">
<MetricWidget
widgetKey={`prompt-${promptSlug}-perf-output-cost`}
title="Output cost per 1k tokens (p50 / p95)"
query={`SELECT timeBucket(), prettyFormat(quantile(0.5)(output_cost / output_tokens * 1000), 'costInDollars') AS p50, prettyFormat(quantile(0.95)(output_cost / output_tokens * 1000), 'costInDollars') AS p95 FROM llm_metrics WHERE output_tokens > 0 GROUP BY timeBucket ORDER BY timeBucket`}
config={{
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["p50", "p95"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
}}
{...widgetProps}
/>
</div>
{/* Row 4: Caching */}
<div className="h-96">
<MetricWidget
widgetKey={`prompt-${promptSlug}-perf-cache-hit`}
title="Cache hit rate over time"
query={`SELECT timeBucket(), round(ifNull(sum(cached_read_tokens) * 100.0 / nullIf(sum(input_tokens), 0), 0), 1) AS cache_hit_pct FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
config={{
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["cache_hit_pct"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
}}
{...widgetProps}
/>
</div>
<div className="h-96">
<MetricWidget
widgetKey={`prompt-${promptSlug}-perf-cached-tokens`}
title="Cached tokens over time"
query={`SELECT timeBucket(), sum(cached_read_tokens) AS cache_reads, sum(cache_creation_tokens) AS cache_writes FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
config={{
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["cache_reads", "cache_writes"],
groupByColumn: null,
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
}}
{...widgetProps}
/>
</div>
</div>
<div className="h-48">
<MetricWidget
widgetKey={`prompt-${promptSlug}-perf-versions-table`}
title="Version summary"
query={`SELECT prompt_version, count() AS calls, round(avg(input_tokens)) AS avg_input_tokens, round(avg(output_tokens)) AS avg_output_tokens, prettyFormat(avg(total_cost), 'costInDollars') AS avg_total_cost, round(quantile(0.5)(duration) / 1000000, 1) AS p50_latency_ms, round(quantile(0.95)(duration) / 1000000, 1) AS p95_latency_ms FROM llm_metrics WHERE 1=1 GROUP BY prompt_version ORDER BY prompt_version DESC`}
config={{ type: "table", prettyFormatting: true, sorting: [] }}
{...widgetProps}
/>
</div>
</div>
);
}
// ─── Prompt Versions Filter ──────────────────────────────
function PromptVersionsFilter({ versions }: { versions: VersionData[] }) {
const { values, replace, del } = useSearchParams();
const selected = values("versions");
const handleChange = (newValues: string[]) => {
replace({ versions: newValues });
};
if (selected.length === 0 || selected.every((v) => v === "")) {
return (
<SelectProvider value={[]} setValue={handleChange} virtualFocus={true}>
<SelectTrigger
icon={<GitBranchPlusIcon className="size-4" />}
variant="secondary/small"
tooltipTitle="Filter by version"
shortcut={{ key: "e" }}
>
<span className="ml-0.5">Versions</span>
</SelectTrigger>
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
<SelectList>
{versions.map((v) => (
<SelectItem key={v.version} value={String(v.version)}>
v{v.version}
{v.labels.includes("current") ? " (current)" : ""}
{v.labels.includes("override") ? " (override)" : ""}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const summary = selected.length === 1 ? `v${selected[0]}` : `${selected.length} versions`;
return (
<SelectProvider value={selected} setValue={handleChange} virtualFocus={true}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Version"
icon={<GitBranchPlusIcon className="size-4" />}
value={summary}
onRemove={() => del(["versions"])}
variant="secondary/small"
/>
</Ariakit.Select>
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
<SelectList>
{versions.map((v) => (
<SelectItem key={v.version} value={String(v.version)}>
v{v.version}
{v.labels.includes("current") ? " (current)" : ""}
{v.labels.includes("override") ? " (override)" : ""}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
// ─── Versions Tab ────────────────────────────────────────
function VersionsTab({
versions,
selectedVersion,
onSelectVersion,
}: {
versions: VersionData[];
selectedVersion: VersionData | undefined;
onSelectVersion: (version: number) => void;
}) {
return (
<div className="divide-y divide-grid-dimmed border-b border-grid-dimmed">
{versions.map((v) => {
const isSelected = selectedVersion?.id === v.id;
const isCurrent = v.labels.includes("current");
const isLatest = v.labels.includes("latest");
const isOverride = v.labels.includes("override");
return (
<div
key={v.id}
onClick={() => onSelectVersion(v.version)}
className={cn(
"flex cursor-pointer items-center gap-3 px-3 py-3 text-sm transition",
isSelected
? "bg-indigo-500/10 hover:bg-indigo-500/[0.07]"
: "hover:bg-background-hover"
)}
>
<RadioButtonCircle checked={isSelected} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div
className={cn(
"size-2 shrink-0 rounded-full",
isOverride ? "bg-amber-400" : isCurrent ? "bg-green-500" : "bg-surface-control"
)}
/>
<span className="font-medium text-text-bright">v{v.version}</span>
{isOverride && (
<Badge variant="extra-small" className="border-amber-500/30 text-amber-400">
override
</Badge>
)}
{isCurrent && <Badge variant="extra-small">current</Badge>}
{isLatest && !isCurrent && <Badge variant="extra-small">latest</Badge>}
<span
className={cn(
"text-xs",
v.source !== "code" ? "text-amber-400" : "text-text-dimmed"
)}
>
{v.source}
</span>
</div>
{(v.model || v.commitMessage) && (
<div className="flex items-center gap-1.5 truncate text-xs text-text-dimmed">
{v.model && <span>{v.model}</span>}
{v.model && v.commitMessage && <span className="text-charcoal-600">/</span>}
{v.commitMessage && <span className="truncate">{v.commitMessage}</span>}
</div>
)}
</div>
<span className="shrink-0 text-xs text-text-dimmed">
<DateTime date={v.createdAt} />
</span>
</div>
);
})}
</div>
);
}
const MAX_DESCRIPTION_PREVIEW = 80;
function PromptCopyPopover({
slug,
friendlyId,
description,
}: {
slug: string;
friendlyId: string;
description: string | null;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="-ml-1.5 flex items-center gap-1 rounded py-1.5 pl-2 pr-1.5 font-mono text-xs text-text-dimmed transition focus-custom hover:bg-background-hover hover:text-text-bright">
{slug}
<ChevronUpDownIcon className="size-4 text-text-faint" />
</PopoverTrigger>
<PopoverContent
align="start"
className="flex min-w-0 flex-col p-1"
onOpenAutoFocus={(e) => {
e.preventDefault();
const el = e.currentTarget as HTMLElement;
el.style.pointerEvents = "none";
requestAnimationFrame(() => {
el.style.pointerEvents = "";
});
}}
>
<CopyPopoverItem label="Copy slug" value={slug} onCopied={() => setOpen(false)} />
<CopyPopoverItem
label="Copy friendly ID"
value={friendlyId}
onCopied={() => setOpen(false)}
/>
{description && (
<CopyPopoverItem
label="Copy description"
value={description}
preview={
description.length > MAX_DESCRIPTION_PREVIEW
? description.slice(0, MAX_DESCRIPTION_PREVIEW) + "…"
: description
}
onCopied={() => setOpen(false)}
/>
)}
</PopoverContent>
</Popover>
);
}
function CopyPopoverItem({
label,
value,
preview,
onCopied,
}: {
label: string;
value: string;
preview?: string;
onCopied?: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => {
setCopied(false);
onCopied?.();
}, 600);
};
return (
<SimpleTooltip
button={
<button
type="button"
onClick={handleCopy}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition",
copied
? "text-green-500"
: "text-text-dimmed hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5 shrink-0" />
) : (
<ClipboardIcon className="size-3.5 shrink-0" />
)}
{label}
</button>
}
content={<span className="max-w-64 break-all font-mono text-xs">{preview ?? value}</span>}
side="right"
disableHoverableContent
asChild
/>
);
}