/**
* Prompt Detail Page (Story 19.10)
*
* Route: /prompts/:id
*
* Features:
* - Version history timeline
* - Content viewer
* - Side-by-side diff between versions
* - Per-version analytics charts
*/
import React, { useState, useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from 'recharts';
import { useApi } from '../hooks/useApi';
import {
getPrompt,
getPromptAnalytics,
getPromptDiff,
createPromptVersion,
deletePrompt,
} from '../api/prompts';
import type {
PromptTemplate,
PromptVersion,
PromptVersionAnalytics,
PromptDiffResponse,
} from '../api/prompts';
// ─── Helpers ────────────────────────────────────────────────────
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
function formatCost(usd: number): string {
if (usd < 0.01) return `$${usd.toFixed(4)}`;
if (usd < 1) return `$${usd.toFixed(3)}`;
return `$${usd.toFixed(2)}`;
}
// ─── Diff Viewer ────────────────────────────────────────────────
function DiffViewer({ diff }: { diff: string }) {
const lines = diff.split('\n');
return (
{lines.map((line, i) => {
let cls = 'text-gray-700';
if (line.startsWith('+') && !line.startsWith('+++')) cls = 'text-green-700 bg-green-50';
else if (line.startsWith('-') && !line.startsWith('---')) cls = 'text-red-700 bg-red-50';
else if (line.startsWith('@@')) cls = 'text-blue-600';
return (
{line}
);
})}
);
}
// ─── New Version Modal ──────────────────────────────────────────
function NewVersionModal({
templateId,
currentContent,
onClose,
onCreated,
}: {
templateId: string;
currentContent: string;
onClose: () => void;
onCreated: () => void;
}) {
const [content, setContent] = useState(currentContent);
const [changelog, setChangelog] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
setSaving(true);
setError('');
try {
await createPromptVersion(templateId, { content, changelog });
onCreated();
onClose();
} catch (err: any) {
setError(err?.message || 'Failed to create version');
} finally {
setSaving(false);
}
};
return (
e.stopPropagation()}>
Create New Version
);
}
// ─── Component ──────────────────────────────────────────────────
export function PromptDetail(): React.ReactElement {
const { id } = useParams<{ id: string }>();
const [selectedVersion, setSelectedVersion] = useState(null);
const [diffV1, setDiffV1] = useState('');
const [diffV2, setDiffV2] = useState('');
const [diffData, setDiffData] = useState(null);
const [diffLoading, setDiffLoading] = useState(false);
const [showNewVersion, setShowNewVersion] = useState(false);
// Fetch template + versions
const { data, loading, error, refetch } = useApi<{
template: PromptTemplate;
versions: PromptVersion[];
}>(() => getPrompt(id!), [id]);
// Fetch analytics
const { data: analytics } = useApi(
() => getPromptAnalytics(id!),
[id],
);
const template = data?.template;
const versions = data?.versions ?? [];
// Auto-select current version
const displayVersion = selectedVersion ?? versions[0] ?? null;
// Load diff
const handleDiff = async () => {
if (!diffV1 || !diffV2 || diffV1 === diffV2) return;
setDiffLoading(true);
try {
const result = await getPromptDiff(id!, diffV1, diffV2);
setDiffData(result);
} catch {
setDiffData(null);
} finally {
setDiffLoading(false);
}
};
// Analytics chart data
const chartData = useMemo(() => {
if (!analytics) return [];
return analytics.map((a) => ({
version: `v${a.versionNumber}`,
calls: a.callCount,
cost: a.totalCostUsd,
latency: a.avgLatencyMs,
errorRate: a.errorRate,
}));
}, [analytics]);
// ── 404 ─────────────────────────────────────────────────────────
if (error?.includes('404')) {
return (
📝
Prompt Not Found
← Back to Prompts
);
}
if (loading) {
return (
);
}
if (error) {
return (
Error loading prompt: {error}
);
}
if (!template) return <>>;
return (
{/* Back + header */}
← Prompts
{template.name}
{template.description && (
{template.description}
)}
{template.category}
Current: v{template.currentVersionNumber}
Updated: {formatDate(template.updatedAt)}
{/* Version list */}
Versions ({versions.length})
{versions.map((v) => (
))}
{/* Content viewer */}
{displayVersion && (
Version {displayVersion.versionNumber} Content
{displayVersion.content}
{displayVersion.variables && displayVersion.variables.length > 0 && (
Variables
{displayVersion.variables.map((v) => (
{'{{' + v.name + '}}'}
{v.required && *}
))}
)}
)}
{/* Diff viewer */}
{versions.length >= 2 && (
Version Diff
→
{diffData &&
}
)}
{/* Analytics */}
{chartData.length > 0 && (
Version Analytics
{/* Calls per version */}
Calls per Version
{/* Cost per version */}
Cost per Version
`$${v}`} />
[formatCost(v), 'Cost']} />
{/* Analytics table */}
{analytics && analytics.length > 0 && (
| Version |
Calls |
Total Cost |
Avg Latency |
Error Rate |
Avg Tokens |
{analytics.map((a) => (
| v{a.versionNumber} |
{a.callCount} |
{formatCost(a.totalCostUsd)} |
{Math.round(a.avgLatencyMs)}ms |
{(a.errorRate * 100).toFixed(1)}% |
{Math.round(a.avgInputTokens + a.avgOutputTokens)} |
))}
)}
)}
{/* New version modal */}
{showNewVersion && (
setShowNewVersion(false)}
onCreated={() => refetch()}
/>
)}
);
}