profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
spine-panel.tsx 3,979 bytes
1 'use client';
2
3 import { useMemo } from 'react';
4 import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
5 import { buildSpineProfiles, type SpineEntry, type SpineVerdict } from '@/core/spine';
6 import type { Participant, RoundRecord } from '@/core/types';
7 import { participantColor, participantTag } from '@/lib/model-visuals';
8
9 /**
10 * Per-model pressure response: for each round, did the model revise, defend,
11 * cave (change a well-scored answer) or stonewall (keep a poorly-scored one)?
12 * Computed client-side from records the debate already produced.
13 */
14 export function SpinePanel({ participants, rounds }: { participants: Participant[]; rounds: RoundRecord[] }) {
15 const profiles = useMemo(() => buildSpineProfiles(participants, rounds), [participants, rounds]);
16 const withEntries = profiles.filter((p) => p.entries.length > 0);
17 if (withEntries.length === 0) return null;
18
19 return (
20 <div className="space-y-3">
21 <p className="text-xs text-muted-foreground">
22 How each model handled peer pressure. Caved: changed an answer the council scored highly.
23 Stonewalled: kept an answer the council scored poorly.
24 </p>
25 <div className="space-y-2">
26 {withEntries.map((profile) => {
27 const idx = participants.findIndex((p) => p.id === profile.participantId);
28 const participant = participants[idx];
29 return (
30 <div key={profile.participantId} className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
31 <div className="flex w-44 min-w-0 items-center gap-1.5">
32 <span
33 className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold text-white"
34 style={{ backgroundColor: participantColor(idx < 0 ? 0 : idx) }}
35 >
36 {participantTag(idx < 0 ? 0 : idx)}
37 </span>
38 <span className="truncate text-xs font-medium">{participant?.displayName ?? profile.model}</span>
39 </div>
40 <div className="flex flex-wrap items-center gap-1.5">
41 {profile.entries.map((entry) => (
42 <VerdictChip key={entry.round} entry={entry} />
43 ))}
44 </div>
45 <span className="text-[11px] text-muted-foreground">{summarize(profile.counts, profile.changeRate)}</span>
46 </div>
47 );
48 })}
49 </div>
50 </div>
51 );
52 }
53
54 const VERDICT_STYLE: Record<SpineVerdict, string> = {
55 revised: 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30',
56 defended: 'bg-muted/60 text-muted-foreground border-border',
57 caved: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30',
58 stonewalled: 'bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/30',
59 };
60
61 function VerdictChip({ entry }: { entry: SpineEntry }) {
62 return (
63 <Tooltip>
64 <TooltipTrigger asChild>
65 <span
66 className={`inline-flex cursor-help items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${VERDICT_STYLE[entry.verdict]}`}
67 >
68 R{entry.round} {entry.verdict}
69 </span>
70 </TooltipTrigger>
71 <TooltipContent side="top" className="text-xs">
72 Round {entry.round}: {entry.changed ? 'changed its answer' : 'kept its answer'}
73 {entry.meanIncomingScore === null
74 ? ', no peer scores received'
75 : `, peers scored it ${entry.meanIncomingScore.toFixed(1)}/10`}
76 .
77 </TooltipContent>
78 </Tooltip>
79 );
80 }
81
82 function summarize(counts: Record<SpineVerdict, number>, changeRate: number | null): string {
83 const flags: string[] = [];
84 if (counts.caved > 0) flags.push(`caved ${counts.caved}x`);
85 if (counts.stonewalled > 0) flags.push(`stonewalled ${counts.stonewalled}x`);
86 const rate = changeRate === null ? '' : `changed ${Math.round(changeRate * 100)}% of rounds`;
87 return [rate, ...flags].filter(Boolean).join(' ยท ');
88 }
89