profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM

Commit

recover no-key start into the key dialog, add re-run prefill from history

commit 3fd0327

5 changed files with +108 and −14

Jump to a changed file
  1. src/app/debate/page.tsx +7 −2
  2. src/components/debate/debate-actions.tsx +7 −1
  3. src/components/debate/new-debate.tsx +76 −9
  4. src/components/history-list.tsx +12 −1
  5. src/hooks/use-debate-stream.ts +6 −1
modified src/app/debate/page.tsx +7 −2
@@ -2,10 +2,15 @@import { NewDebate } from '@/components/debate/new-debate';
2 2
3 3 export const dynamic = 'force-dynamic';
4 4
5 -export default function DebatePage() {
5 +export default async function DebatePage({
6 + searchParams,
7 +}: {
8 + searchParams: Promise<{ from?: string }>;
9 +}) {
10 + const { from } = await searchParams;
6 11 return (
7 12 <div className="container py-8">
8 - <NewDebate />
13 + <NewDebate fromDebateId={from} />
9 14 </div>
10 15 );
11 16 }
modified src/components/debate/debate-actions.tsx +7 −1
@@ -1,6 +1,7 @@
1 1 'use client';
2 2
3 -import { Check, Download, Share2 } from 'lucide-react';
3 +import { Check, Download, RotateCcw, Share2 } from 'lucide-react';
4 +import Link from 'next/link';
4 5 import { useState } from 'react';
5 6 import { Button } from '@/components/ui/button';
6 7 import { toast } from '@/components/ui/sonner';
@@ -23,6 +24,11 @@export function DebateActions({ debateId }: { debateId: string }) {
23 24
24 25 return (
25 26 <div className="flex items-center gap-2">
27 + <Button variant="outline" size="sm" asChild>
28 + <Link href={`/debate?from=${debateId}`}>
29 + <RotateCcw className="h-3.5 w-3.5" /> Re-run
30 + </Link>
31 + </Button>
26 32 <Button variant="outline" size="sm" asChild>
27 33 <a href={`/api/debates/${debateId}/export?format=md`} download>
28 34 <Download className="h-3.5 w-3.5" /> Export
modified src/components/debate/new-debate.tsx +76 −9
@@ -1,7 +1,8 @@
1 1 'use client';
2 2
3 -import { AlertTriangle, Loader2, Play, RotateCcw, Save, Sparkles, Square } from 'lucide-react';
3 +import { AlertTriangle, KeyRound, Loader2, Play, RotateCcw, Save, Sparkles, Square } from 'lucide-react';
4 4 import { useEffect, useMemo, useState } from 'react';
5 +import { ApiKeyDialog } from '@/components/api-key-dialog';
5 6 import { DebateConsole } from '@/components/debate/debate-console';
6 7 import { ModelCombobox } from '@/components/debate/model-combobox';
7 8 import { ModelPicker } from '@/components/debate/model-picker';
@@ -13,6 +14,7 @@import { Label } from '@/components/ui/label';
13 14 import { Slider } from '@/components/ui/slider';
14 15 import { toast } from '@/components/ui/sonner';
15 16 import { chairmanSharesProvider, DEFAULT_CONVERGENCE_MODEL } from '@/core/models';
17 +import type { DebateResult } from '@/core/types';
16 18 import { useDebateStream } from '@/hooks/use-debate-stream';
17 19 import { useModels } from '@/hooks/use-models';
18 20 import { estimateDebateCostUsd } from '@/lib/cost-estimate';
@@ -31,8 +33,8 @@interface Preset {
31 33
32 34 const EXAMPLE = 'Should a small startup build on a monolith or microservices? Give a decisive recommendation.';
33 35
34 -export function NewDebate() {
35 - const { view, phase, errorMsg, start, cancel, reset } = useDebateStream();
36 +export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
37 + const { view, phase, errorMsg, errorCode, start, cancel, reset } = useDebateStream();
36 38 const { models } = useModels();
37 39
38 40 const [question, setQuestion] = useState('');
@@ -44,6 +46,42 @@export function NewDebate() {
44 46 const [temperature, setTemperature] = useState(0.7);
45 47 const [presets, setPresets] = useState<Preset[]>([]);
46 48 const [presetName, setPresetName] = useState('');
49 + const [keyDialogOpen, setKeyDialogOpen] = useState(false);
50 + const [needsKey, setNeedsKey] = useState(false);
51 + const [prefilled, setPrefilled] = useState(false);
52 +
53 + // Prefill the whole form from a previous debate (/debate?from=<id>).
54 + useEffect(() => {
55 + if (!fromDebateId || prefilled) return;
56 + let cancelled = false;
57 + fetch(`/api/debates/${fromDebateId}`)
58 + .then((r) => (r.ok ? (r.json() as Promise<DebateResult>) : null))
59 + .then((result) => {
60 + if (!result || cancelled) return;
61 + setQuestion(result.config.question);
62 + setCouncil(result.config.models);
63 + setChairman(result.config.chairmanModel);
64 + setConvergenceModel(result.config.convergenceModel);
65 + setMaxRounds(result.config.maxRounds);
66 + setThreshold(result.config.convergenceThreshold);
67 + setTemperature(result.config.temperature);
68 + setPrefilled(true);
69 + toast.message('Loaded question and council from the previous debate');
70 + })
71 + .catch(() => {});
72 + return () => {
73 + cancelled = true;
74 + };
75 + }, [fromDebateId, prefilled]);
76 +
77 + // A NO_KEY failure is a setup problem, not a debate failure: send the user
78 + // straight to the key dialog with their composed debate intact.
79 + useEffect(() => {
80 + if (errorCode !== 'NO_KEY') return;
81 + setNeedsKey(true);
82 + setKeyDialogOpen(true);
83 + reset();
84 + }, [errorCode, reset]);
47 85
48 86 // Seed a sensible default council + chairman once the catalog arrives.
49 87 useEffect(() => {
@@ -70,7 +108,17 @@export function NewDebate() {
70 108 }, []);
71 109
72 110 const providerConflict = chairman && council.length > 0 && chairmanSharesProvider(chairman, council);
73 - const canStart = question.trim().length >= 3 && council.length >= 3 && council.length <= 6 && Boolean(chairman);
111 + const startBlocker =
112 + question.trim().length < 3
113 + ? 'Write a question to get started'
114 + : council.length < 3
115 + ? `Pick at least 3 council models (${council.length} selected)`
116 + : council.length > 6
117 + ? 'A council holds at most 6 models'
118 + : !chairman
119 + ? 'Pick a chairman to write the final verdict'
120 + : null;
121 + const canStart = startBlocker === null;
74 122 const running = phase === 'connecting' || phase === 'streaming';
75 123
76 124 const priceMap = useMemo(
@@ -287,22 +335,41 @@export function NewDebate() {
287 335 </CardContent>
288 336 </Card>
289 337
338 + {needsKey && (
339 + <div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm">
340 + <KeyRound className="h-4 w-4 text-amber-500" />
341 + <span>Running a real debate needs an OpenRouter key. Add one, then press Start again.</span>
342 + <Button variant="outline" size="sm" className="ml-auto" onClick={() => setKeyDialogOpen(true)}>
343 + Add key
344 + </Button>
345 + </div>
346 + )}
347 +
290 348 {errorMsg && (
291 349 <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
292 350 <AlertTriangle className="h-4 w-4" /> {errorMsg}
293 351 </div>
294 352 )}
295 353
296 354 <div className="sticky bottom-4 flex items-center justify-end gap-3">
297 - {canStart && (
298 - <span className="rounded-md bg-background/80 px-2.5 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur">
299 - ~{formatUsd(estimate)} est. · up to {maxRounds} {maxRounds === 1 ? 'round' : 'rounds'}
300 - </span>
301 - )}
355 + <span className="rounded-md bg-background/80 px-2.5 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur">
356 + {canStart
357 + ? `~${formatUsd(estimate)} est. · up to ${maxRounds} ${maxRounds === 1 ? 'round' : 'rounds'}`
358 + : startBlocker}
359 + </span>
302 360 <Button size="lg" disabled={!canStart} onClick={launch} className="shadow-lg">
303 361 <Play className="h-4 w-4" /> Start debate
304 362 </Button>
305 363 </div>
364 +
365 + <ApiKeyDialog
366 + open={keyDialogOpen}
367 + onOpenChange={setKeyDialogOpen}
368 + onChanged={() => {
369 + setNeedsKey(false);
370 + setKeyDialogOpen(false);
371 + }}
372 + />
306 373 </div>
307 374 );
308 375 }
modified src/components/history-list.tsx +12 −1
@@ -1,6 +1,6 @@
1 1 'use client';
2 2
3 -import { Coins, MessageSquare, Search, Trash2 } from 'lucide-react';
3 +import { Coins, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react';
4 4 import Link from 'next/link';
5 5 import { useRouter } from 'next/navigation';
6 6 import { useMemo, useState } from 'react';
@@ -74,6 +74,17 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) {
74 74 </div>
75 75 </Link>
76 76 {d.shareToken && <Badge variant="secondary">shared</Badge>}
77 + <Button
78 + variant="ghost"
79 + size="icon"
80 + className="opacity-0 transition-opacity group-hover:opacity-100"
81 + asChild
82 + aria-label="Re-run with this question and council"
83 + >
84 + <Link href={`/debate?from=${d.id}`}>
85 + <RotateCcw className="h-4 w-4" />
86 + </Link>
87 + </Button>
77 88 <Button
78 89 variant="ghost"
79 90 size="icon"
modified src/hooks/use-debate-stream.ts +6 −1
@@ -17,6 +17,8 @@export function useDebateStream() {
17 17 const [view, setView] = useState<DebateView>(() => initialDebateView());
18 18 const [phase, setPhase] = useState<StreamPhase>('idle');
19 19 const [errorMsg, setErrorMsg] = useState<string | null>(null);
20 + /** Machine-readable error code from the API (e.g. 'NO_KEY'), when present. */
21 + const [errorCode, setErrorCode] = useState<string | null>(null);
20 22 const abortRef = useRef<AbortController | null>(null);
21 23
22 24 const handleFrame = useCallback((frame: string) => {
@@ -61,6 +63,7 @@export function useDebateStream() {
61 63 abortRef.current = controller;
62 64 setView(initialDebateView(config.question));
63 65 setErrorMsg(null);
66 + setErrorCode(null);
64 67 setPhase('connecting');
65 68
66 69 try {
@@ -74,6 +77,7 @@export function useDebateStream() {
74 77 if (!res.ok || !res.body) {
75 78 const err = (await res.json().catch(() => null)) as { error?: string; code?: string } | null;
76 79 setErrorMsg(err?.error ?? `Request failed (${res.status})`);
80 + setErrorCode(err?.code ?? null);
77 81 setPhase('error');
78 82 return;
79 83 }
@@ -109,8 +113,9 @@export function useDebateStream() {
109 113 abortRef.current?.abort();
110 114 setView(initialDebateView());
111 115 setErrorMsg(null);
116 + setErrorCode(null);
112 117 setPhase('idle');
113 118 }, []);
114 119
115 - return { view, phase, errorMsg, start, cancel, reset };
120 + return { view, phase, errorMsg, errorCode, start, cancel, reset };
116 121 }