Make the page playable: interactive board, live solver, and a usable run picker
Three things the build was quietly missing. The interactive board was never mounted. The contract has `interactive.init` and `interactive.Controls`, the demo implemented both, and the shell's `split-play` beat rendered only the replay — so the beat titled "you and the model get the same word" showed one board. PlayYourself now renders the visitor's attempt from the same seed as the run beside it, generically: it knows only the contract, so any demo shipping an interactive mode gets it and one that does not renders nothing rather than an empty pane. solver.worker.ts was dead code — nothing constructed it, which is how CI caught it: `new Worker(` appeared nowhere in the bundle. It is wired now behind "what would the best player guess?", and it answers in 92ms from a real worker on boards no recording covers. That is the difference between a demo and a video. It also surfaces the moment the solver picks a word that CANNOT win, which is the counterweight visible in one line instead of explained in a paragraph. The CI check that found it was itself wrong: it grepped every bundled file for `blob:`, which React's own code contains in a scheme check, so it failed on a risk that was not present. It now greps for worker construction from a blob, which is the thing production CSP actually blocks in silence. And the run switcher was thirty buttons carrying four distinct labels. Split into arm and seed, holding the seed across an arm change — comparing two agents means comparing them on the same hidden word, and silently jumping seeds would break that while looking fine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -19,6 +19,7 @@ import { EnvAnatomy } from './EnvAnatomy';
|
||||
import { LimitsCallout } from './LimitsCallout';
|
||||
import { MetricMover } from './MetricMover';
|
||||
import { ModelCallPanel } from './ModelCallPanel';
|
||||
import { PlayYourself } from './PlayYourself';
|
||||
import { ProvenanceCard } from './ProvenanceCard';
|
||||
import { ReasoningDrawer } from './ReasoningDrawer';
|
||||
import { ReasoningPanel } from './ReasoningPanel';
|
||||
@@ -351,6 +352,19 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
case 'split-play':
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{demo.interactive ? (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 lg:items-start">
|
||||
<PlayYourself demo={demo} seed={run.seed} />
|
||||
<div className="space-y-2">
|
||||
<h3 className={st.h3}>What the model did</h3>
|
||||
<p className={cn(st.prose, 'text-sm')}>
|
||||
Same hidden answer, same rules, same budget. Its attempt is
|
||||
below, replayed at the speed it actually happened.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runs.length > 1 ? (
|
||||
<RunSwitcher
|
||||
runs={runs}
|
||||
@@ -569,22 +583,71 @@ function RunSwitcher({
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
// Two axes, not one list. With four arms over eight seeds a flat control is
|
||||
// thirty buttons carrying four distinct labels, which reads as a bug. Arms
|
||||
// are grouped by `label` because that is what an arm IS in the manifest —
|
||||
// the shell has no other notion of one, and inventing a field for it would
|
||||
// put demo-specific structure into the contract.
|
||||
const arms = useMemo(() => {
|
||||
const byLabel = new Map<string, RunRef[]>();
|
||||
for (const run of runs) {
|
||||
const list = byLabel.get(run.label);
|
||||
if (list) list.push(run);
|
||||
else byLabel.set(run.label, [run]);
|
||||
}
|
||||
return [...byLabel.entries()].map(([label, group]) => ({ label, runs: group }));
|
||||
}, [runs]);
|
||||
|
||||
const active = runs.find((r) => r.id === activeId) ?? runs[0];
|
||||
if (!active) return null;
|
||||
const activeArm = arms.find((a) => a.label === active.label) ?? arms[0];
|
||||
if (!activeArm) return null;
|
||||
|
||||
const pickArm = (label: string) => {
|
||||
const arm = arms.find((a) => a.label === label);
|
||||
if (!arm) return;
|
||||
// Hold the seed across an arm change where the arm has it. Comparing two
|
||||
// agents means comparing them on the SAME hidden word; silently jumping to
|
||||
// a different seed would make the comparison meaningless while looking fine.
|
||||
const sameSeed = arm.runs.find((r) => r.seed === active.seed);
|
||||
onSelect((sameSeed ?? arm.runs[0])!.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
label="Recorded run"
|
||||
options={runs.map((run) => ({
|
||||
value: run.id,
|
||||
label: run.label,
|
||||
...(run.intervention ? { title: run.intervention } : {}),
|
||||
}))}
|
||||
value={activeId}
|
||||
onChange={onSelect}
|
||||
className="border border-border p-1"
|
||||
optionClassName="tap px-3 text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<SegmentedControl
|
||||
label="Agent"
|
||||
options={arms.map((arm) => {
|
||||
const intervention = arm.runs.find((r) => r.intervention)?.intervention;
|
||||
return {
|
||||
value: arm.label,
|
||||
label: arm.label,
|
||||
...(intervention ? { title: intervention } : {}),
|
||||
};
|
||||
})}
|
||||
value={activeArm.label}
|
||||
onChange={pickArm}
|
||||
className="border border-border p-1"
|
||||
optionClassName="tap px-3 text-sm"
|
||||
/>
|
||||
{activeArm.runs.length > 1 ? (
|
||||
<SegmentedControl
|
||||
label="Hidden word"
|
||||
options={activeArm.runs.map((run) => ({
|
||||
value: run.id,
|
||||
label: `#${run.seed}`,
|
||||
}))}
|
||||
value={active.id}
|
||||
onChange={onSelect}
|
||||
className="border border-border p-1"
|
||||
optionClassName="tap px-2.5 text-xs nums"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The loading state. Shaped like the page it becomes, and with no spinner: a
|
||||
* spinner here would imply a live model call, which is the one thing the whole
|
||||
|
||||
Reference in New Issue
Block a user