Zayne Rea Sprague
fix: move Raw Prompt dropdown inside model action bubble
20b1a79
import { useState } from "react";
import type { EpisodeData, TranscriptTurn } from "../types";
import { highlightTrace } from "../utils/traceHighlight";
export interface DragHandleProps {
draggable: true;
onDragStart: (e: React.DragEvent) => void;
onDragEnd: (e: React.DragEvent) => void;
}
interface TranscriptPanelProps {
datasetName: string;
repoName?: string;
data: EpisodeData | undefined;
dragHandleProps?: DragHandleProps;
}
const OUTCOME_STYLES = {
win: { bg: "bg-green-900", text: "text-green-300", label: "WIN" },
loss: { bg: "bg-red-900", text: "text-red-300", label: "LOSS" },
error: { bg: "bg-yellow-900", text: "text-yellow-300", label: "ERROR" },
unknown: { bg: "bg-gray-700", text: "text-gray-300", label: "?" },
};
const PLAYER_COLORS: Record<number, { bubble: string; label: string; name: string }> = {
0: { bubble: "bg-purple-900/60 border-purple-700", label: "text-purple-400", name: "Player 0" },
1: { bubble: "bg-orange-900/60 border-orange-700", label: "text-orange-400", name: "Player 1" },
2: { bubble: "bg-purple-900/60 border-purple-700", label: "text-purple-400", name: "Player 2" },
3: { bubble: "bg-teal-900/60 border-teal-700", label: "text-teal-400", name: "Player 3" },
};
function getPlayerColor(playerId: number) {
return PLAYER_COLORS[playerId] || PLAYER_COLORS[0];
}
export default function TranscriptPanel({ datasetName, repoName, data, dragHandleProps }: TranscriptPanelProps) {
if (!data) {
return (
<div className="h-full border border-gray-700 rounded-lg flex items-center justify-center">
<div className="text-gray-500 text-sm">No data</div>
</div>
);
}
const outcomeStyle = OUTCOME_STYLES[data.outcome];
const borderColor = data.outcome === "win" ? "border-green-600"
: data.outcome === "loss" ? "border-red-600"
: data.outcome === "error" ? "border-yellow-600"
: "border-gray-700";
return (
<div className={`h-full border-2 ${borderColor} rounded-lg flex flex-col bg-gray-900/50`}>
{/* Header */}
<div className="px-3 py-2 border-b border-gray-700 shrink-0">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-gray-200 truncate" title={repoName || datasetName}>
{datasetName}
</span>
<span className={`px-1.5 py-0.5 text-[10px] rounded font-medium ${outcomeStyle.bg} ${outcomeStyle.text}`}>
{outcomeStyle.label}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
{dragHandleProps && (
<span
{...dragHandleProps}
title="Drag to reorder"
className="drag-handle text-gray-600 hover:text-gray-400 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="3" r="1.5" />
<circle cx="11" cy="3" r="1.5" />
<circle cx="5" cy="8" r="1.5" />
<circle cx="11" cy="8" r="1.5" />
<circle cx="5" cy="13" r="1.5" />
<circle cx="11" cy="13" r="1.5" />
</svg>
</span>
)}
</div>
</div>
<div className="flex items-center gap-3 text-[10px] text-gray-500">
<span>{data.model}</span>
<span>{data.num_turns} turns</span>
{data.reward !== null && <span>reward: {data.reward}</span>}
{data.opponent_model && <span>vs {data.opponent_model}</span>}
</div>
</div>
{/* Error banner */}
{data.error && (
<div className="px-3 py-1.5 bg-red-900/30 border-b border-red-800/50 text-xs text-red-300">
Error: {data.error}
</div>
)}
{/* Transcript chat */}
<div className="flex-1 overflow-y-auto transcript-scroll px-3 py-2 space-y-3">
{/* System prompt */}
{data.system_prompt && (
<SystemPromptBubble text={data.system_prompt} />
)}
{data.transcript.map((turn, i) => (
<TurnBubble
key={i}
turn={turn}
turnIndex={i}
allTurns={data.transcript}
systemPrompt={data.system_prompt}
hasMultiplePlayers={data.opponent_model !== null}
/>
))}
</div>
</div>
);
}
function SystemPromptBubble({ text }: { text: string }) {
const [expanded, setExpanded] = useState(true);
return (
<div className="mb-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 text-[10px] text-purple-400 hover:text-purple-300 transition-colors mb-1 font-semibold uppercase tracking-wider"
>
<span>{expanded ? "\u25BC" : "\u25B6"}</span>
<span>System Prompt</span>
</button>
{expanded && (
<div className="bg-purple-950/40 border border-purple-800/50 rounded-lg px-3 py-2">
<pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-purple-200">
{text}
</pre>
</div>
)}
</div>
);
}
interface TurnBubbleProps {
turn: TranscriptTurn;
turnIndex: number;
allTurns: TranscriptTurn[];
systemPrompt: string | null;
hasMultiplePlayers: boolean;
}
function buildRawPrompt(
turnIndex: number,
allTurns: TranscriptTurn[],
systemPrompt: string | null,
): object[] {
const messages: object[] = [];
if (systemPrompt) {
messages.push({ role: "system", content: systemPrompt });
}
for (let i = 0; i < turnIndex; i++) {
messages.push({ role: "user", content: allTurns[i].observation });
messages.push({ role: "assistant", content: allTurns[i].action });
}
messages.push({ role: "user", content: allTurns[turnIndex].observation });
return messages;
}
function TurnBubble({ turn, turnIndex, allTurns, systemPrompt, hasMultiplePlayers }: TurnBubbleProps) {
const [thinkExpanded, setThinkExpanded] = useState(false);
const [rawPromptExpanded, setRawPromptExpanded] = useState(false);
const playerColor = getPlayerColor(turn.player_id);
const thinkSegments = highlightTrace(turn.think_text);
return (
<div>
{/* Turn number marker */}
<div className="text-[10px] text-gray-600 mb-1">Turn {turn.turn}</div>
{/* Observation (environment message) — left aligned */}
{turn.observation && (
<div className="flex justify-start mb-1.5">
<div className="max-w-[90%]">
<div className="text-[10px] text-gray-500 mb-0.5 font-semibold uppercase tracking-wider">ENV</div>
<div className="bg-gray-800 border border-gray-700 rounded-lg rounded-tl-none px-3 py-2">
<pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-gray-300">
{turn.observation}
</pre>
</div>
</div>
</div>
)}
{/* Action (model response) — right aligned */}
<div className="flex justify-end">
<div className="max-w-[90%]">
<div className={`text-[10px] mb-0.5 font-semibold uppercase tracking-wider text-right ${playerColor.label}`}>
{hasMultiplePlayers ? `${playerColor.name} (${turn.player_id === 0 ? "model" : "opponent"})` : "Model"}
</div>
<div className={`border rounded-lg rounded-tr-none px-3 py-2 ${playerColor.bubble}`}>
{/* Raw Prompt — collapsible, shows full messages sent to API */}
<div className="mb-2">
<button
onClick={() => setRawPromptExpanded(!rawPromptExpanded)}
className="flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-400 transition-colors mb-1"
>
<span>{rawPromptExpanded ? "\u25BC" : "\u25B6"}</span>
<span>Raw Prompt ({turnIndex * 2 + 1 + (systemPrompt ? 1 : 0)} messages)</span>
</button>
{rawPromptExpanded && (
<pre className="text-[10px] leading-relaxed whitespace-pre-wrap font-mono bg-gray-950/80 border border-gray-800 rounded px-2 py-1.5 text-gray-400 max-h-80 overflow-y-auto">
{JSON.stringify(buildRawPrompt(turnIndex, allTurns, systemPrompt), null, 2)}
</pre>
)}
</div>
{/* Think section — collapsible */}
{turn.think_text && (
<div className="mb-2">
<button
onClick={() => setThinkExpanded(!thinkExpanded)}
className="flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-400 transition-colors mb-1"
>
<span>{thinkExpanded ? "\u25BC" : "\u25B6"}</span>
<span>Thinking ({turn.think_len.toLocaleString()} chars{turn.backtracks > 0 ? `, ${turn.backtracks} backtracks` : ""})</span>
</button>
{thinkExpanded && (
<pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono border-l-2 border-gray-600 pl-2 mt-1">
{thinkSegments.map((seg, i) => (
<span key={i} className={seg.className}>{seg.text}</span>
))}
</pre>
)}
</div>
)}
{/* Action text (the actual game move) */}
<pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-gray-100 font-medium">
{turn.action_text || turn.action || "(no action)"}
</pre>
</div>
</div>
</div>
</div>
);
}