profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
debate-console.tsx 10,874 bytes
1 'use client';
2
3 import { AlertTriangle, CircleCheckBig, Gavel, PiggyBank, Scale } from 'lucide-react';
4 import { CostBreakdown } from '@/components/debate/cost-breakdown';
5 import { CostMeter } from '@/components/debate/cost-meter';
6 import { CritiqueMatrix } from '@/components/debate/critique-matrix';
7 import { DebateActions } from '@/components/debate/debate-actions';
8 import { Disagreements } from '@/components/debate/disagreements';
9 import { FinalAnswer } from '@/components/debate/final-answer';
10 import { MasqueradeNote } from '@/components/debate/masquerade-note';
11 import { ModelPanel } from '@/components/debate/model-panel';
12 import { RevisionDiff } from '@/components/debate/revision-diff';
13 import { SpinePanel } from '@/components/debate/spine-panel';
14 import { StageTimeline } from '@/components/debate/stage-timeline';
15 import { Badge } from '@/components/ui/badge';
16 import { Progress } from '@/components/ui/progress';
17 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
18 import type { AnswerRecord } from '@/core/types';
19 import { allCritiques, currentAnswers, type DebateView } from '@/lib/debate-view';
20 import { formatUsd } from '@/lib/utils';
21
22 export function DebateConsole({
23 view,
24 showActions = true,
25 }: {
26 view: DebateView;
27 showActions?: boolean;
28 }) {
29 const answers = currentAnswers(view);
30 const dropped = new Set(view.droppedParticipants);
31 const lastConvergence =
32 [...view.rounds].reverse().find((r) => r.convergence)?.convergence ?? null;
33 const finished =
34 view.status === 'completed' || view.status === 'failed' || view.status === 'aborted';
35
36 return (
37 <div className="space-y-6">
38 {/* Header */}
39 <div className="space-y-4">
40 <div className="flex flex-wrap items-start justify-between gap-3">
41 <div className="min-w-0 space-y-1">
42 <div className="flex items-center gap-2">
43 <StatusBadge status={view.status} />
44 <span className="text-xs text-muted-foreground">
45 {view.participants.length} independent opinions ยท up to{' '}
46 {view.config?.maxRounds ?? '-'} review rounds
47 </span>
48 </div>
49 <h1 className="text-balance font-display text-2xl font-medium leading-snug tracking-tight">
50 {view.question}
51 </h1>
52 </div>
53 <div className="flex items-center gap-2">
54 <CostMeter view={view} />
55 {showActions && finished && view.debateId && (
56 <DebateActions debateId={view.debateId} question={view.question} />
57 )}
58 </div>
59 </div>
60
61 {view.error && (
62 <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
63 <AlertTriangle className="h-4 w-4" /> {view.error}
64 </div>
65 )}
66
67 {view.gavelStruck && (
68 <div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 p-3 text-sm">
69 <Gavel className="h-4 w-4 text-primary" />
70 <span>
71 You asked for the verdict after review round {view.gavelStruck.round}. It was prepared
72 from the opinions completed at that point.
73 </span>
74 </div>
75 )}
76
77 {view.budgetReached && (
78 <div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm">
79 <PiggyBank className="h-4 w-4 text-amber-500" />
80 <span>
81 Budget cap reached after round {view.budgetReached.round} (spent{' '}
82 {formatUsd(view.budgetReached.totalCostUsd)} of a{' '}
83 {formatUsd(view.budgetReached.maxCostUsd)} cap); remaining rounds were skipped and the
84 verdict was prepared from the work completed so far.
85 </span>
86 </div>
87 )}
88
89 <StageTimeline view={view} />
90 </div>
91
92 {/* Final answer + disagreements (surfaced up top once available) */}
93 {view.synthesis && (
94 <section id="section-final" className="grid gap-4 lg:grid-cols-[1fr_320px]">
95 <FinalAnswer
96 synthesis={view.synthesis}
97 participants={view.participants}
98 chairmanProviderConflict={view.chairmanProviderConflict}
99 provenance={view.provenance}
100 />
101 {lastConvergence && lastConvergence.disagreements.length > 0 ? (
102 <Disagreements
103 disagreements={lastConvergence.disagreements}
104 participants={view.participants}
105 />
106 ) : (
107 <div className="rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-sm">
108 <div className="mb-1 flex items-center gap-2 font-medium text-emerald-600 dark:text-emerald-400">
109 <CircleCheckBig className="h-4 w-4" /> Converged
110 </div>
111 <p className="text-muted-foreground">
112 The council reached substantial agreement
113 {lastConvergence ? ` (score ${lastConvergence.score}/100)` : ''}.
114 </p>
115 </div>
116 )}
117 </section>
118 )}
119
120 {/* Council panels */}
121 <section id="section-council" className="space-y-3">
122 <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
123 Independent opinions
124 </h2>
125 <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
126 {view.participants.map((p, i) => (
127 <ModelPanel
128 key={p.id}
129 index={i}
130 displayName={p.displayName}
131 model={p.model}
132 answer={answers[p.id]}
133 streamingText={view.streaming[p.id]}
134 workingStage={view.working[p.id]}
135 dropped={dropped.has(p.id)}
136 />
137 ))}
138 </div>
139 </section>
140
141 {/* Rounds */}
142 {view.rounds.map((round) => (
143 <RoundSection
144 key={round.round}
145 round={round.round}
146 view={view}
147 answersBefore={answersBeforeRound(view, round.round)}
148 />
149 ))}
150
151 {/* Pressure response */}
152 {view.rounds.some((r) => r.revisions.length > 0) && (
153 <section id="section-spine" className="space-y-3">
154 <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
155 How opinions changed
156 </h2>
157 <div className="rounded-xl border bg-card p-4">
158 <SpinePanel participants={view.participants} rounds={view.rounds} />
159 </div>
160 {finished && (
161 <MasqueradeNote participants={view.participants} critiques={allCritiques(view)} />
162 )}
163 </section>
164 )}
165
166 {/* Cost breakdown */}
167 {finished && (
168 <section id="section-cost" className="space-y-3">
169 <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
170 Usage and cost
171 </h2>
172 <div className="rounded-xl border bg-card p-4">
173 <CostBreakdown view={view} />
174 </div>
175 </section>
176 )}
177 </div>
178 );
179 }
180
181 function RoundSection({
182 round,
183 view,
184 answersBefore,
185 }: {
186 round: number;
187 view: DebateView;
188 answersBefore: Record<string, AnswerRecord>;
189 }) {
190 const record = view.rounds.find((r) => r.round === round)!;
191 return (
192 <section id={`section-round-${round}`} className="rounded-xl border bg-card">
193 <div className="flex flex-wrap items-center gap-2 border-b p-4">
194 <span
195 className="flex h-7 w-7 items-center justify-center rounded-md text-white"
196 style={{ backgroundColor: 'hsl(var(--stage-critique))' }}
197 >
198 <Scale className="h-4 w-4" />
199 </span>
200 <h2 className="text-sm font-semibold">Review round {round}</h2>
201 {record.convergence && (
202 <div className="ml-auto flex items-center gap-2">
203 <span className="text-xs text-muted-foreground">agreement</span>
204 <div className="w-28">
205 <Progress value={record.convergence.score} />
206 </div>
207 <Badge variant={record.convergence.converged ? 'success' : 'secondary'}>
208 {record.convergence.score}/100
209 </Badge>
210 </div>
211 )}
212 </div>
213
214 <div className="p-4">
215 <Tabs defaultValue="matrix">
216 <TabsList>
217 <TabsTrigger value="matrix">Peer review</TabsTrigger>
218 <TabsTrigger value="revisions">Answer changes</TabsTrigger>
219 </TabsList>
220 <TabsContent value="matrix" className="pt-2">
221 <CritiqueMatrix participants={view.participants} critiques={record.critiques} />
222 </TabsContent>
223 <TabsContent value="revisions" className="space-y-4 pt-2">
224 {record.revisions.length === 0 && (
225 <p className="text-sm text-muted-foreground">No revisions recorded yet.</p>
226 )}
227 {record.revisions.map((rev) => {
228 const before = answersBefore[rev.participantId];
229 const idx = view.participants.findIndex((p) => p.id === rev.participantId);
230 return (
231 <RevisionDiff
232 key={rev.participantId}
233 index={idx < 0 ? 0 : idx}
234 displayName={view.participants[idx]?.displayName ?? rev.model}
235 previous={before?.content ?? ''}
236 next={rev.content}
237 changelog={rev.changelog}
238 />
239 );
240 })}
241 </TabsContent>
242 </Tabs>
243 </div>
244 </section>
245 );
246 }
247
248 /** Answer each participant held entering a given round (for the diff baseline). */
249 function answersBeforeRound(view: DebateView, round: number): Record<string, AnswerRecord> {
250 const out: Record<string, AnswerRecord> = { ...view.initialAnswers };
251 for (const r of view.rounds) {
252 if (r.round >= round) break;
253 for (const rev of r.revisions) {
254 out[rev.participantId] = {
255 participantId: rev.participantId,
256 model: rev.model,
257 round: rev.round,
258 content: rev.content,
259 usage: rev.usage,
260 latencyMs: rev.latencyMs,
261 };
262 }
263 }
264 return out;
265 }
266
267 function StatusBadge({ status }: { status: DebateView['status'] }) {
268 const map: Record<
269 DebateView['status'],
270 { variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning'; label: string }
271 > = {
272 pending: { variant: 'secondary', label: 'Getting ready' },
273 running: { variant: 'warning', label: 'Comparing opinions' },
274 completed: { variant: 'success', label: 'Verdict ready' },
275 failed: { variant: 'destructive', label: 'Failed' },
276 aborted: { variant: 'secondary', label: 'Aborted' },
277 };
278 const s = map[status];
279 return <Badge variant={s.variant}>{s.label}</Badge>;
280 }
281