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 = ({ 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>["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; 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 { 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(); 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 ( } backButton={{ to: v3PromptsPath(organization, project, environment), text: "Prompts" }} />
{selectedVersion && (
v{selectedVersion.version} {isCurrent && current} {selectedVersion.labels.includes("override") && ( override )}
)} {selectedVersion && !isCurrent && selectedVersion.source === "code" && ( handlePromote(selectedVersion.id)} disabled={fetcher.state !== "idle"} > Promote to current )} {selectedVersion && selectedVersion.source !== "code" && !selectedVersion.labels.includes("override") && ( fetcher.submit( { intent: "reactivateOverride", versionId: selectedVersion.id }, { method: "POST" } ) } disabled={fetcher.state !== "idle"} > Reactivate as override )} {!overrideVersion && ( setOverrideDialogOpen(true)} > Create override )}
{overrideVersion && ( Override v{overrideVersion.version} is active. API calls resolve to this version instead of the deployed prompt.
setOverrideDialogOpen(true)} > Edit fetcher.submit({ intent: "removeOverride" }, { method: "POST" })} disabled={fetcher.state !== "idle"} > Remove
)}
{/* Main content */} {/* Template panel */} {content ? ( ) : (
No content
)}
{/* Tabs panel */}
{/* Tab bar */}
replaceSearch({ contentTab: "generations", tab, version: searchValue("version"), period: searchValue("period"), from: searchValue("from"), to: searchValue("to"), }) } > Generations replaceSearch({ contentTab: "metrics", tab, version: searchValue("version"), period: searchValue("period"), from: searchValue("from"), to: searchValue("to"), }) } > Metrics
({ model: m, system: "" }))} />
{/* Tab content */}
{contentTab === "generations" && ( )} {contentTab === "metrics" && (
)}
{/* Sidebar */}
{/* Tabs */}
replaceSearch({ tab: "details", version: versionParam })} shortcut={{ key: "d" }} > Details replaceSearch({ tab: "preview", version: versionParam })} shortcut={{ key: "i" }} > Preview replaceSearch({ tab: "versions", version: versionParam })} shortcut={{ key: "v" }} > Versions
{/* Tab content */}
{tab === "details" && ( )} {tab === "preview" && } {tab === "versions" && ( )}
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); }} /> ); } // ─── 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(); 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 ( {isEditingOverride ? "Edit override" : "Create override"} {/* Editor */} {/* Right panel: properties */}
Override settings setCommitMessage(e.target.value)} /> setModel(e.target.value)} /> Override the default model for this version
{/* Variable schema (read-only) */} {variableFields.length > 0 && (
{variableFields.map((f) => (
{f.name} {f.type} {f.required && ( required )}
))}
)} {/* Validation warnings */} {(undefinedVars.length > 0 || unusedVars.length > 0) && (
{undefinedVars.length > 0 && (
Undefined variables:{" "} {undefinedVars.map((v) => ( {`{{${v}}}`} ))}
)} {unusedVars.length > 0 && (
Required but missing:{" "} {unusedVars.map((v) => ( {v.name} ))}
)}
)}
{/* Footer */}
); } // ─── Details Tab ───────────────────────────────────────── function DetailsTab({ prompt, selectedVersion, }: { prompt: ReturnType>["prompt"]; selectedVersion?: VersionData; }) { const effectiveModel = selectedVersion?.model ?? prompt.defaultModel; return (
Slug {prompt.slug} {prompt.description && ( Description {prompt.description} )} {effectiveModel && ( Model {effectiveModel} )} {prompt.defaultConfig && ( Config )} {prompt.filePath && ( Source {prompt.filePath} {prompt.exportName ? ` (${prompt.exportName})` : ""} )} {prompt.tags.length > 0 && ( Tags
{prompt.tags.map((tag) => ( {tag} ))}
)}
{prompt.variableSchema && (
Variable schema
)}
); } // ─── Preview Tab ───────────────────────────────────────── function PreviewTab({ prompt, content, }: { prompt: ReturnType>["prompt"]; content: string; }) { const variableFields = prompt.variableSchema ? extractVariableFields(prompt.variableSchema) : []; const [testVariables, setTestVariables] = useState>(() => 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 (
{variableFields.length > 0 ? ( <>
Variables Fill in values to preview your resolved prompt template.
{variableFields.map((field, index) => ( {field.enumValues ? ( ) : field.isLongText ? (