profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

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

Commit

Make Roundtable easier to use and share

commit 4a53203

26 changed files with +1485 and −769

Jump to a changed file
  1. src/app/debate/page.tsx +6 −4
  2. src/app/demo/page.tsx +44 −31
  3. src/app/globals.css +51 −41
  4. src/app/history/page.tsx +16 −10
  5. src/app/icon.svg +1 −1
  6. src/app/layout.tsx +14 −16
  7. src/app/manifest.ts +4 −3
  8. src/app/page.tsx +169 −81
  9. src/app/r/[token]/opengraph-image.tsx +182 −0
  10. src/app/r/[token]/page.tsx +86 −14
  11. src/app/signin/page.tsx +9 −4
  12. src/components/api-key-dialog.tsx +25 −14
  13. src/components/debate/debate-actions.tsx +49 −13
  14. src/components/debate/debate-console.tsx +48 −23
  15. src/components/debate/disagreements.tsx +9 −3
  16. src/components/debate/final-answer.tsx +107 −88
  17. src/components/debate/new-debate.tsx +344 −227
  18. src/components/debate/stage-timeline.tsx +16 −10
  19. src/components/decision-starter.tsx +71 −0
  20. src/components/history-list.tsx +11 −6
  21. src/components/key-button.tsx +15 −4
  22. src/components/landing-background.tsx +5 −34
  23. src/components/landing-hero.tsx +163 −132
  24. src/components/site-header.tsx +13 −9
  25. src/lib/question-examples.ts +26 −0
  26. tailwind.config.ts +1 −1
modified src/app/debate/page.tsx +6 −4
@@ -5,12 +5,14 @@export const dynamic = 'force-dynamic';
5 5 export default async function DebatePage({
6 6 searchParams,
7 7 }: {
8 - searchParams: Promise<{ from?: string }>;
8 + searchParams: Promise<{ from?: string | string[]; q?: string | string[] }>;
9 9 }) {
10 - const { from } = await searchParams;
10 + const { from, q } = await searchParams;
11 + const fromDebateId = Array.isArray(from) ? from[0] : from;
12 + const initialQuestion = Array.isArray(q) ? q[0] : q;
11 13 return (
12 - <div className="container py-8">
13 - <NewDebate fromDebateId={from} />
14 + <div className="container py-8 sm:py-12">
15 + <NewDebate fromDebateId={fromDebateId} initialQuestion={initialQuestion} />
14 16 </div>
15 17 );
16 18 }
modified src/app/demo/page.tsx +44 −31
@@ -1,5 +1,6 @@
1 -import { ArrowRight } from 'lucide-react';
1 +import { ArrowRight, PlayCircle, UsersRound } from 'lucide-react';
2 2 import Link from 'next/link';
3 +import { Badge } from '@/components/ui/badge';
3 4 import { Button } from '@/components/ui/button';
4 5 import { displayNameForModel } from '@/core/types';
5 6 import { listDemoDebates } from '@/db/repositories';
@@ -16,51 +17,63 @@export default async function DemoPage() {
16 17 }
17 18
18 19 return (
19 - <div className="container max-w-3xl py-12 sm:py-16">
20 - <div className="space-y-3 pb-10">
21 - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">recorded debates</p>
22 - <h1 className="font-display text-3xl font-medium tracking-tight sm:text-4xl">
23 - Watch a council <em className="text-primary">argue</em>.
20 + <div className="container max-w-5xl py-12 sm:py-16">
21 + <div className="mx-auto max-w-2xl pb-12 text-center">
22 + <Badge variant="secondary" className="mb-4 gap-1.5 rounded-full">
23 + <PlayCircle className="h-3.5 w-3.5 text-primary" />
24 + Example results
25 + </Badge>
26 + <h1 className="font-display text-4xl font-semibold tracking-[-0.04em] sm:text-5xl">
27 + See how a better answer takes shape.
24 28 </h1>
25 - <p className="max-w-xl text-sm leading-relaxed text-muted-foreground">
26 - Real multi-model debates, recorded and replayed through the full UI - streaming, critique matrix, revision
27 - diffs, and all. No account or API key needed.
29 + <p className="mx-auto mt-4 max-w-xl leading-relaxed text-muted-foreground">
30 + Replay real roundtables from the first independent opinions through the final verdict. No
31 + account or model connection is needed.
28 32 </p>
29 33 </div>
30 34
31 35 {demos.length === 0 ? (
32 - <div className="border-t py-10 text-sm text-muted-foreground">
33 - No demo debates seeded yet. Run <code className="font-mono">pnpm db:seed</code> to add them.
36 + <div className="rounded-2xl border border-dashed p-10 text-center text-sm text-muted-foreground">
37 + No examples are available on this deployment yet.
34 38 </div>
35 39 ) : (
36 - <ul className="divide-y border-t">
37 - {demos.map((d) => (
38 - <li key={d.id}>
39 - <Link href={`/demo/${d.id}`} className="group flex items-center gap-6 py-7">
40 - <div className="min-w-0 flex-1">
41 - <h2 className="font-display text-xl font-medium leading-snug tracking-tight sm:text-2xl">
42 - {d.question}
43 - </h2>
44 - <p className="mt-2 font-mono text-xs text-muted-foreground">
45 - {d.models.map(displayNameForModel).join(' · ')}
46 - <span className="text-muted-foreground/60">
47 - {' '}
48 - · {d.roundsCompleted} rounds · {formatUsd(d.totalCostUsd)}
49 - </span>
40 + <ul className="grid gap-4 md:grid-cols-2">
41 + {demos.map((demo) => (
42 + <li key={demo.id}>
43 + <Link
44 + href={`/demo/${demo.id}`}
45 + className="group flex h-full flex-col rounded-2xl border bg-card p-5 transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:shadow-lg hover:shadow-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:p-6"
46 + >
47 + <div className="flex items-center justify-between">
48 + <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
49 + <UsersRound className="h-3.5 w-3.5" />
50 + {demo.models.length} opinions
51 + </span>
52 + <span className="flex h-9 w-9 items-center justify-center rounded-full border text-muted-foreground transition-colors group-hover:border-primary group-hover:bg-primary group-hover:text-primary-foreground">
53 + <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
54 + </span>
55 + </div>
56 + <h2 className="mt-8 font-display text-xl font-semibold leading-snug tracking-[-0.025em] sm:text-2xl">
57 + {demo.question}
58 + </h2>
59 + <div className="mt-auto pt-8 text-xs text-muted-foreground">
60 + <p className="truncate">{demo.models.map(displayNameForModel).join(' · ')}</p>
61 + <p className="mt-1">
62 + {demo.roundsCompleted} review rounds · {formatUsd(demo.totalCostUsd)}
50 63 </p>
51 64 </div>
52 - <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border text-muted-foreground transition-colors group-hover:border-primary group-hover:text-primary">
53 - <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
54 - </span>
55 65 </Link>
56 66 </li>
57 67 ))}
58 68 </ul>
59 69 )}
60 70
61 - <div className="border-t pt-8">
62 - <Button asChild>
63 - <Link href="/debate">Run your own debate</Link>
71 + <div className="mt-10 flex justify-center">
72 + <Button size="lg" asChild className="rounded-xl">
73 + <Link href="/debate">
74 + Ask your own question
75 + <ArrowRight className="h-4 w-4" />
76 + </Link>
64 77 </Button>
65 78 </div>
66 79 </div>
modified src/app/globals.css +51 −41
@@ -4,64 +4,64 @@
4 4
5 5 @layer base {
6 6 :root {
7 - --background: 0 0% 100%;
8 - --foreground: 222 47% 11%;
7 + --background: 228 33% 98%;
8 + --foreground: 231 28% 13%;
9 9
10 10 --card: 0 0% 100%;
11 - --card-foreground: 222 47% 11%;
11 + --card-foreground: 231 28% 13%;
12 12 --popover: 0 0% 100%;
13 - --popover-foreground: 222 47% 11%;
13 + --popover-foreground: 231 28% 13%;
14 14
15 - --primary: 243 75% 59%;
15 + --primary: 235 78% 58%;
16 16 --primary-foreground: 0 0% 100%;
17 - --secondary: 220 14% 96%;
18 - --secondary-foreground: 222 47% 11%;
19 - --muted: 220 14% 96%;
20 - --muted-foreground: 220 9% 46%;
21 - --accent: 243 75% 96%;
22 - --accent-foreground: 243 75% 40%;
17 + --secondary: 230 24% 94%;
18 + --secondary-foreground: 231 28% 16%;
19 + --muted: 225 20% 95%;
20 + --muted-foreground: 226 11% 43%;
21 + --accent: 235 70% 95%;
22 + --accent-foreground: 235 70% 38%;
23 23 --destructive: 0 72% 51%;
24 24 --destructive-foreground: 0 0% 100%;
25 25
26 - --border: 220 13% 91%;
27 - --input: 220 13% 88%;
28 - --ring: 243 75% 59%;
29 - --radius: 0.75rem;
26 + --border: 228 20% 88%;
27 + --input: 228 18% 84%;
28 + --ring: 235 78% 58%;
29 + --radius: 0.9rem;
30 30
31 - --stage-answer: 217 91% 60%;
32 - --stage-critique: 32 95% 54%;
33 - --stage-revision: 262 83% 63%;
34 - --stage-synthesis: 158 64% 42%;
31 + --stage-answer: 215 91% 58%;
32 + --stage-critique: 23 91% 58%;
33 + --stage-revision: 276 70% 57%;
34 + --stage-synthesis: 157 64% 39%;
35 35 }
36 36
37 37 .dark {
38 - --background: 224 32% 8%;
39 - --foreground: 210 40% 96%;
38 + --background: 230 28% 8%;
39 + --foreground: 225 30% 96%;
40 40
41 - --card: 224 28% 11%;
42 - --card-foreground: 210 40% 96%;
43 - --popover: 224 28% 10%;
44 - --popover-foreground: 210 40% 96%;
41 + --card: 230 25% 11%;
42 + --card-foreground: 225 30% 96%;
43 + --popover: 230 25% 10%;
44 + --popover-foreground: 225 30% 96%;
45 45
46 - --primary: 243 80% 67%;
47 - --primary-foreground: 224 32% 8%;
48 - --secondary: 222 20% 18%;
49 - --secondary-foreground: 210 40% 96%;
50 - --muted: 222 20% 16%;
51 - --muted-foreground: 217 15% 62%;
52 - --accent: 243 40% 22%;
53 - --accent-foreground: 243 90% 85%;
46 + --primary: 234 86% 69%;
47 + --primary-foreground: 230 28% 8%;
48 + --secondary: 229 20% 18%;
49 + --secondary-foreground: 225 30% 96%;
50 + --muted: 229 20% 16%;
51 + --muted-foreground: 225 14% 65%;
52 + --accent: 234 38% 22%;
53 + --accent-foreground: 234 90% 86%;
54 54 --destructive: 0 63% 50%;
55 55 --destructive-foreground: 0 0% 100%;
56 56
57 - --border: 222 20% 20%;
58 - --input: 222 20% 22%;
59 - --ring: 243 80% 67%;
57 + --border: 229 19% 21%;
58 + --input: 229 19% 24%;
59 + --ring: 234 86% 69%;
60 60
61 - --stage-answer: 217 91% 65%;
62 - --stage-critique: 32 95% 60%;
63 - --stage-revision: 262 83% 70%;
64 - --stage-synthesis: 158 64% 50%;
61 + --stage-answer: 215 91% 66%;
62 + --stage-critique: 23 91% 64%;
63 + --stage-revision: 276 74% 70%;
64 + --stage-synthesis: 157 62% 49%;
65 65 }
66 66 }
67 67
@@ -71,9 +71,14 @@
71 71 }
72 72 body {
73 73 @apply bg-background text-foreground;
74 - font-feature-settings: 'rlig' 1, 'calt' 1;
74 + font-feature-settings:
75 + 'rlig' 1,
76 + 'calt' 1;
75 77 -webkit-font-smoothing: antialiased;
76 78 }
79 + ::selection {
80 + background: hsl(var(--primary) / 0.2);
81 + }
77 82 /* Thin, unobtrusive scrollbars for the dense debate panels. */
78 83 .scrollbar-thin {
79 84 scrollbar-width: thin;
@@ -105,6 +110,11 @@
105 110 background-image: radial-gradient(hsl(var(--border)) 1px, transparent 1px);
106 111 background-size: 22px 22px;
107 112 }
113 + .decision-grid {
114 + background-image: linear-gradient(hsl(var(--border) / 0.5) 1px, transparent 1px),
115 + linear-gradient(90deg, hsl(var(--border) / 0.5) 1px, transparent 1px);
116 + background-size: 44px 44px;
117 + }
108 118 /* Fine film grain for atmospheric depth. */
109 119 .bg-grain {
110 120 background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
modified src/app/history/page.tsx +16 −10
@@ -15,10 +15,12 @@export default async function HistoryPage() {
15 15 if (!userId) {
16 16 return (
17 17 <div className="container flex flex-col items-center gap-4 py-24 text-center">
18 - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">history</p>
19 - <h1 className="font-display text-3xl font-medium tracking-tight">Every debate, kept.</h1>
18 + <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
19 + past questions
20 + </p>
21 + <h1 className="font-display text-3xl font-semibold tracking-tight">Keep every verdict.</h1>
20 22 <p className="max-w-md text-sm leading-relaxed text-muted-foreground">
21 - Sign in to keep a searchable, replayable history of every debate you run.
23 + Sign in to search, revisit, and share the questions you have put to the table.
22 24 </p>
23 25 {isGithubAuthConfigured ? (
24 26 <form
@@ -31,12 +33,12 @@export default async function HistoryPage() {
31 33 </form>
32 34 ) : (
33 35 <p className="text-xs text-muted-foreground">
34 - GitHub auth is not configured on this deployment. Debates you run are still available via their direct
35 - links.
36 + GitHub sign-in is not configured on this deployment. Your results are still available
37 + from their direct links.
36 38 </p>
37 39 )}
38 40 <Button variant="outline" asChild>
39 - <Link href="/debate">Start a debate anyway</Link>
41 + <Link href="/debate">Ask a question anyway</Link>
40 42 </Button>
41 43 </div>
42 44 );
@@ -48,13 +50,17 @@export default async function HistoryPage() {
48 50 <div className="container max-w-4xl py-12">
49 51 <div className="mb-8 flex flex-wrap items-end justify-between gap-3 border-b pb-6">
50 52 <div className="space-y-1.5">
51 - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">history</p>
52 - <h1 className="font-display text-3xl font-medium tracking-tight">
53 - {debates.length} {debates.length === 1 ? 'debate' : 'debates'}
53 + <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
54 + past questions
55 + </p>
56 + <h1 className="font-display text-3xl font-semibold tracking-tight">
57 + {debates.length} {debates.length === 1 ? 'question' : 'questions'}
54 58 </h1>
55 59 </div>
56 60 <div className="text-right">
57 - <div className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">this month</div>
61 + <div className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
62 + this month
63 + </div>
58 64 <div className="mt-1 font-display text-2xl font-medium">{formatUsd(spend)}</div>
59 65 </div>
60 66 </div>
modified src/app/icon.svg +1 −1
@@ -1,5 +1,5 @@
1 1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2 - <rect width="32" height="32" rx="7" fill="#6366f1" />
2 + <rect width="32" height="32" rx="8" fill="#4f5ee8" />
3 3 <circle cx="16" cy="9" r="3" fill="#ffffff" />
4 4 <circle cx="9" cy="21" r="3" fill="#ffffff" />
5 5 <circle cx="23" cy="21" r="3" fill="#ffffff" />
modified src/app/layout.tsx +14 −16
@@ -1,50 +1,48 @@
1 1 import type { Metadata } from 'next';
2 -import { Fraunces, Inter, JetBrains_Mono } from 'next/font/google';
2 +import { JetBrains_Mono, Manrope, Sora } from 'next/font/google';
3 3 import { SiteHeader } from '@/components/site-header';
4 4 import { ThemeProvider } from '@/components/theme-provider';
5 5 import { Toaster } from '@/components/ui/sonner';
6 6 import { TooltipProvider } from '@/components/ui/tooltip';
7 7 import './globals.css';
8 8
9 -const inter = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap' });
9 +const sans = Manrope({ subsets: ['latin'], variable: '--font-sans', display: 'swap' });
10 10 const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono', display: 'swap' });
11 -// Distinctive editorial serif for display headings (landing page).
12 -const display = Fraunces({
13 - subsets: ['latin'],
14 - variable: '--font-display',
15 - display: 'swap',
16 - axes: ['opsz', 'SOFT'],
17 -});
11 +const display = Sora({ subsets: ['latin'], variable: '--font-display', display: 'swap' });
18 12
19 13 const DESCRIPTION =
20 - 'A council of LLMs that debate over several rounds - critiquing and revising each other - before a chairman synthesizes a final answer and a dissent report.';
14 + 'Ask several leading AI models one question. They challenge each other, improve their answers, and give you one clear verdict with any remaining disagreement.';
21 15
22 16 export const metadata: Metadata = {
23 17 metadataBase: new URL(process.env.AUTH_URL || 'http://localhost:3000'),
24 18 title: {
25 - default: 'Roundtable - multi-model LLM debate',
26 - template: '%s · Roundtable',
19 + default: 'Roundtable | Several AI opinions, one clear verdict',
20 + template: '%s | Roundtable',
27 21 },
28 22 description: DESCRIPTION,
29 23 applicationName: 'Roundtable',
30 24 openGraph: {
31 - title: 'Roundtable - multi-model LLM debate',
25 + title: 'Roundtable | Several AI opinions, one clear verdict',
32 26 description: DESCRIPTION,
33 27 type: 'website',
34 28 siteName: 'Roundtable',
35 29 },
36 30 twitter: {
37 31 card: 'summary_large_image',
38 - title: 'Roundtable - multi-model LLM debate',
32 + title: 'Roundtable | Several AI opinions, one clear verdict',
39 33 description: DESCRIPTION,
40 34 },
41 35 };
42 36
43 37 export default function RootLayout({ children }: { children: React.ReactNode }) {
44 38 return (
45 - <html lang="en" suppressHydrationWarning className={`${inter.variable} ${mono.variable} ${display.variable}`}>
39 + <html
40 + lang="en"
41 + suppressHydrationWarning
42 + className={`${sans.variable} ${mono.variable} ${display.variable}`}
43 + >
46 44 <body className="min-h-screen font-sans antialiased">
47 - <ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
45 + <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
48 46 <TooltipProvider delayDuration={200}>
49 47 <div className="relative flex min-h-screen flex-col">
50 48 <SiteHeader />
modified src/app/manifest.ts +4 −3
@@ -4,11 +4,12 @@export default function manifest(): MetadataRoute.Manifest {
4 4 return {
5 5 name: 'Roundtable',
6 6 short_name: 'Roundtable',
7 - description: 'A council of LLMs that debate over several rounds before producing a synthesized answer.',
7 + description:
8 + 'Ask several AI models one question and get one checked verdict with any disagreement still visible.',
8 9 start_url: '/',
9 10 display: 'standalone',
10 - background_color: '#0b1020',
11 - theme_color: '#6366f1',
11 + background_color: '#f8f9fc',
12 + theme_color: '#4f5ee8',
12 13 icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml' }],
13 14 };
14 15 }
modified src/app/page.tsx +169 −81
@@ -1,20 +1,49 @@
1 -import { ArrowRight, Gavel, GitCompareArrows, MessagesSquare, PencilLine, Scale, Users } from 'lucide-react';
1 +import {
2 + ArrowRight,
3 + CheckCircle2,
4 + GitCompareArrows,
5 + MessageCircleQuestion,
6 + MessagesSquare,
7 + Scale,
8 + Sparkles,
9 +} from 'lucide-react';
2 10 import Link from 'next/link';
3 11 import { LandingBackground } from '@/components/landing-background';
4 12 import { LandingHero } from '@/components/landing-hero';
5 13 import { Reveal } from '@/components/reveal';
6 14 import { Button } from '@/components/ui/button';
7 15 import type { DebateResult } from '@/core/types';
8 16 import { listDemoDebates, loadDebateResult } from '@/db/repositories';
17 +import { QUESTION_EXAMPLES } from '@/lib/question-examples';
9 18
10 19 export const dynamic = 'force-dynamic';
11 20
12 21 const STEPS = [
13 - { icon: MessagesSquare, title: 'Answer', desc: 'Each model answers independently, in parallel.', color: 'var(--stage-answer)' },
14 - { icon: Scale, title: 'Critique', desc: 'They score each other, blind to authorship.', color: 'var(--stage-critique)' },
15 - { icon: PencilLine, title: 'Revise', desc: 'Fix what the critiques got right, or defend it.', color: 'var(--stage-revision)' },
16 - { icon: Users, title: 'Converge', desc: 'A fast model checks how far apart they still are.', color: 'var(--stage-answer)' },
17 - { icon: Gavel, title: 'Synthesize', desc: 'A chairman writes the verdict and the dissent.', color: 'var(--stage-synthesis)' },
22 + {
23 + icon: MessageCircleQuestion,
24 + title: 'Ask one clear question',
25 + description:
26 + 'Add your options, priorities, and limits. Roundtable chooses a balanced set of models for you.',
27 + },
28 + {
29 + icon: MessagesSquare,
30 + title: 'Hear independent views',
31 + description:
32 + 'Each model answers on its own before seeing the others, so the first opinion cannot sway the room.',
33 + },
34 + {
35 + icon: Scale,
36 + title: 'Get a checked verdict',
37 + description:
38 + 'They challenge weak points, revise their answers, and combine the strongest reasoning into one result.',
39 + },
40 +];
41 +
42 +const REASONS = [
43 + 'One clear recommendation comes first',
44 + 'Remaining disagreements stay visible',
45 + 'Every original opinion can be inspected',
46 + 'Cost is estimated before anything runs',
18 47 ];
19 48
20 49 export default async function HomePage() {
@@ -27,108 +56,167 @@export default async function HomePage() {
27 56 }
28 57
29 58 return (
30 - <div>
59 + <div className="overflow-hidden">
31 60 <LandingBackground />
32 - {demo ? <LandingHero result={demo} /> : <MinimalHero />}
61 + <LandingHero result={demo} />
33 62
34 - {/* The five moves of a debate, as a colored pipeline (no boxes). */}
35 - <section className="container border-t py-16 sm:py-24">
36 - <Reveal className="mx-auto max-w-2xl text-center">
37 - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">how a debate unfolds</p>
38 - <h2 className="mt-3 font-display text-3xl font-medium tracking-tight sm:text-4xl">
39 - Five moves from question to verdict
40 - </h2>
41 - </Reveal>
42 - <ol className="relative mx-auto mt-14 grid max-w-5xl gap-y-10 sm:grid-cols-5 sm:gap-x-4">
43 - <div
44 - aria-hidden
45 - className="absolute left-[10%] right-[10%] top-6 hidden h-px bg-gradient-to-r from-stage-answer/50 via-stage-revision/50 to-stage-synthesis/50 sm:block"
46 - />
47 - {STEPS.map((step, i) => (
48 - <li key={step.title} className="relative flex flex-col items-center px-2 text-center">
49 - <span
50 - className="relative z-10 flex h-12 w-12 items-center justify-center rounded-full border-2 bg-background"
51 - style={{ borderColor: `hsl(${step.color})`, color: `hsl(${step.color})` }}
63 + <section className="container py-16 sm:py-24">
64 + <Reveal className="grid gap-8 lg:grid-cols-[0.7fr_1.3fr] lg:gap-16">
65 + <div>
66 + <p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">
67 + Made for real decisions
68 + </p>
69 + <h2 className="mt-3 max-w-md font-display text-3xl font-semibold tracking-[-0.035em] sm:text-4xl">
70 + Bring the messy question.
71 + </h2>
72 + <p className="mt-4 max-w-md leading-relaxed text-muted-foreground">
73 + Roundtable is most useful when there is no obvious answer, several tradeoffs matter,
74 + and you want a recommendation you can inspect.
75 + </p>
76 + </div>
77 + <div className="grid gap-3 sm:grid-cols-2">
78 + {QUESTION_EXAMPLES.map((example) => (
79 + <Link
80 + key={example.category}
81 + href={{ pathname: '/debate', query: { q: example.question } }}
82 + className="group rounded-2xl border bg-card/70 p-5 transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:bg-card hover:shadow-lg hover:shadow-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
52 83 >
53 - <step.icon className="h-5 w-5" strokeWidth={1.75} />
54 - </span>
55 - <span className="mt-4 font-mono text-[10px] uppercase tracking-widest text-muted-foreground/70">
56 - Step {i + 1}
84 + <div className="flex items-center justify-between">
85 + <span className="text-[11px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
86 + {example.category}
87 + </span>
88 + <ArrowRight className="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
89 + </div>
90 + <h3 className="mt-6 font-display text-xl font-semibold tracking-tight">
91 + {example.short}
92 + </h3>
93 + <p className="mt-2 text-sm leading-relaxed text-muted-foreground">
94 + {example.question}
95 + </p>
96 + </Link>
97 + ))}
98 + </div>
99 + </Reveal>
100 + </section>
101 +
102 + <section className="border-y bg-card/45">
103 + <div className="container py-16 sm:py-24">
104 + <Reveal className="mx-auto max-w-2xl text-center">
105 + <p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">
106 + How it works
107 + </p>
108 + <h2 className="mt-3 font-display text-3xl font-semibold tracking-[-0.035em] sm:text-4xl">
109 + More than three chat tabs.
110 + </h2>
111 + <p className="mt-4 text-muted-foreground">
112 + Roundtable keeps the opinions independent, then makes the models test one another
113 + before you see the final answer.
114 + </p>
115 + </Reveal>
116 +
117 + <ol className="mx-auto mt-12 grid max-w-6xl gap-4 md:grid-cols-3">
118 + {STEPS.map((step, index) => (
119 + <Reveal key={step.title} delay={index * 90}>
120 + <li className="relative h-full rounded-2xl border bg-background p-6">
121 + <div className="flex items-center justify-between">
122 + <span className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
123 + <step.icon className="h-5 w-5" />
124 + </span>
125 + <span className="font-mono text-xs text-muted-foreground/60">0{index + 1}</span>
126 + </div>
127 + <h3 className="mt-8 font-display text-xl font-semibold tracking-tight">
128 + {step.title}
129 + </h3>
130 + <p className="mt-3 text-sm leading-relaxed text-muted-foreground">
131 + {step.description}
132 + </p>
133 + </li>
134 + </Reveal>
135 + ))}
136 + </ol>
137 + </div>
138 + </section>
139 +
140 + <section className="container py-16 sm:py-24">
141 + <Reveal className="relative overflow-hidden rounded-[2rem] border bg-foreground px-6 py-10 text-background sm:px-10 sm:py-14 lg:px-14">
142 + <div className="absolute right-[-8%] top-[-40%] h-80 w-80 rounded-full bg-primary/40 blur-3xl" />
143 + <div className="relative grid items-center gap-10 lg:grid-cols-[1fr_0.85fr]">
144 + <div>
145 + <span className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-[0.18em] text-background/60">
146 + <Sparkles className="h-3.5 w-3.5" />
147 + Clear without hiding the details
57 148 </span>
58 - <h3 className="mt-1 font-semibold">{step.title}</h3>
59 - <p className="mt-1.5 max-w-[15rem] text-sm leading-relaxed text-muted-foreground">{step.desc}</p>
60 - </li>
61 - ))}
62 - </ol>
149 + <h2 className="mt-4 max-w-xl font-display text-3xl font-semibold tracking-[-0.035em] sm:text-5xl">
150 + A verdict you can question, share, and act on.
151 + </h2>
152 + <p className="mt-5 max-w-xl leading-relaxed text-background/70">
153 + Most AI gives you a confident answer and asks you to trust it. Roundtable keeps the
154 + reasoning, changes, and dissent attached to the result.
155 + </p>
156 + </div>
157 + <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
158 + {REASONS.map((reason) => (
159 + <li
160 + key={reason}
161 + className="flex items-center gap-3 rounded-xl border border-background/15 bg-background/5 p-3.5 text-sm"
162 + >
163 + <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-400" />
164 + {reason}
165 + </li>
166 + ))}
167 + </ul>
168 + </div>
169 + </Reveal>
63 170 </section>
64 171
65 - <section className="container border-t py-24 sm:py-32">
66 - <Reveal className="mx-auto max-w-3xl text-center">
67 - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
68 - your question, deliberated
172 + <section className="container pb-20 pt-6 text-center sm:pb-28 sm:pt-10">
173 + <Reveal className="mx-auto max-w-3xl">
174 + <p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">
175 + Your next decision
69 176 </p>
70 - <h2 className="mx-auto mt-4 max-w-2xl font-display text-4xl font-medium leading-[1.04] tracking-tight sm:text-6xl">
71 - Stop trusting one model&apos;s <em className="text-primary">first guess</em>.
177 + <h2 className="mt-4 font-display text-4xl font-semibold leading-tight tracking-[-0.04em] sm:text-6xl">
178 + Put it on the table.
72 179 </h2>
73 - <p className="mx-auto mt-6 max-w-xl text-lg leading-relaxed text-muted-foreground">
74 - Convene a council, watch them reason in the open, and get an answer you can actually inspect.
180 + <p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-muted-foreground">
181 + Start with smart defaults, or open the controls when you want to choose every model and
182 + rule yourself.
75 183 </p>
76 - <div className="mt-9 flex flex-wrap items-center justify-center gap-3">
77 - <Button size="lg" asChild className="group">
184 + <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
185 + <Button size="lg" asChild className="rounded-xl px-7">
78 186 <Link href="/debate">
79 - Start a debate
80 - <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
187 + Ask a question
188 + <ArrowRight className="h-4 w-4" />
81 189 </Link>
82 190 </Button>
83 191 <Button size="lg" variant="ghost" asChild>
84 - <Link href="/demo">Watch a recording</Link>
192 + <Link href="/demo">See example results</Link>
85 193 </Button>
86 194 </div>
87 195 </Reveal>
88 196 </section>
89 197
90 198 <footer className="border-t">
91 - <div className="container flex flex-col items-center justify-between gap-4 py-10 text-sm text-muted-foreground sm:flex-row">
92 - <span className="flex items-center gap-2 font-medium text-foreground">
93 - <GitCompareArrows className="h-4 w-4 text-primary" /> Roundtable
94 - </span>
199 + <div className="container flex flex-col items-center justify-between gap-5 py-9 text-sm text-muted-foreground sm:flex-row">
200 + <Link
201 + href="/"
202 + className="flex items-center gap-2 font-display font-semibold text-foreground"
203 + >
204 + <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
205 + <GitCompareArrows className="h-4 w-4" />
206 + </span>
207 + Roundtable
208 + </Link>
95 209 <nav className="flex items-center gap-6">
96 210 <Link href="/debate" className="transition-colors hover:text-foreground">
97 - New debate
211 + Ask
98 212 </Link>
99 213 <Link href="/demo" className="transition-colors hover:text-foreground">
100 - Demo
214 + Examples
101 215 </Link>
102 216 </nav>
103 - <span className="text-xs">Next.js · Prisma · OpenRouter · Vercel AI SDK</span>
217 + <span className="text-xs">Several opinions. One clear verdict.</span>
104 218 </div>
105 219 </footer>
106 220 </div>
107 221 );
108 222 }
109 -
110 -/** Shown only when no demo debate has been seeded yet. */
111 -function MinimalHero() {
112 - return (
113 - <section className="container max-w-2xl py-28 text-center">
114 - <h1 className="font-display text-4xl font-medium tracking-tight sm:text-6xl">
115 - Convene a <em className="text-primary">roundtable</em>.
116 - </h1>
117 - <p className="mx-auto mt-5 max-w-xl text-lg text-muted-foreground">
118 - A council of models answers your question, critiques each other blind, and revises across rounds. A chairman
119 - writes the final answer and an honest account of where they still disagree.
120 - </p>
121 - <div className="mt-8 flex justify-center gap-3">
122 - <Button size="lg" asChild>
123 - <Link href="/debate">Start a debate</Link>
124 - </Button>
125 - <Button size="lg" variant="ghost" asChild>
126 - <Link href="/demo">Watch a recorded one</Link>
127 - </Button>
128 - </div>
129 - <p className="mt-5 text-sm text-muted-foreground">
130 - No demo is seeded on this deployment yet. Run <code className="font-mono">pnpm db:seed</code> to add one.
131 - </p>
132 - </section>
133 - );
134 -}
added src/app/r/[token]/opengraph-image.tsx +182 −0
@@ -0,0 +1,182 @@
1 +import { ImageResponse } from 'next/og';
2 +import { loadDebateByShareToken } from '@/db/repositories';
3 +import { truncate } from '@/lib/utils';
4 +
5 +export const alt = 'A shared Roundtable verdict';
6 +export const size = { width: 1200, height: 630 };
7 +export const contentType = 'image/png';
8 +export const dynamic = 'force-dynamic';
9 +
10 +export default async function SharedVerdictImage({
11 + params,
12 +}: {
13 + params: Promise<{ token: string }>;
14 +}) {
15 + const { token } = await params;
16 + const result = await loadDebateByShareToken(token).catch(() => null);
17 + const question = truncate(result?.config.question ?? 'A question put to the Roundtable', 160);
18 + const verdict = truncate(
19 + result?.synthesis?.finalAnswer ??
20 + 'Several independent AI opinions were compared, challenged, and combined into one clear recommendation.',
21 + 300,
22 + );
23 + const opinionCount = result?.participants.length ?? 3;
24 +
25 + return new ImageResponse(
26 + (
27 + <div
28 + style={{
29 + position: 'relative',
30 + display: 'flex',
31 + height: '100%',
32 + width: '100%',
33 + overflow: 'hidden',
34 + background: '#f7f8fc',
35 + color: '#171a2b',
36 + fontFamily: 'Arial, sans-serif',
37 + padding: '58px 64px',
38 + }}
39 + >
40 + <div
41 + style={{
42 + position: 'absolute',
43 + right: '-180px',
44 + top: '-230px',
45 + display: 'flex',
46 + height: '620px',
47 + width: '620px',
48 + border: '2px solid rgba(79, 94, 232, 0.12)',
49 + borderRadius: '50%',
50 + }}
51 + />
52 + <div
53 + style={{
54 + position: 'absolute',
55 + right: '-80px',
56 + top: '-130px',
57 + display: 'flex',
58 + height: '420px',
59 + width: '420px',
60 + border: '2px solid rgba(79, 94, 232, 0.12)',
61 + borderRadius: '50%',
62 + }}
63 + />
64 +
65 + <div
66 + style={{ position: 'relative', display: 'flex', width: '100%', flexDirection: 'column' }}
67 + >
68 + <div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
69 + <div
70 + style={{
71 + display: 'flex',
72 + height: '42px',
73 + width: '42px',
74 + alignItems: 'center',
75 + justifyContent: 'center',
76 + borderRadius: '12px',
77 + background: '#4f5ee8',
78 + color: 'white',
79 + fontSize: '20px',
80 + fontWeight: 800,
81 + }}
82 + >
83 + R
84 + </div>
85 + <div style={{ display: 'flex', fontSize: '21px', fontWeight: 800 }}>Roundtable</div>
86 + <div
87 + style={{
88 + display: 'flex',
89 + marginLeft: '10px',
90 + borderRadius: '999px',
91 + background: '#e9ebf8',
92 + color: '#596078',
93 + padding: '8px 13px',
94 + fontSize: '12px',
95 + fontWeight: 700,
96 + letterSpacing: '0.08em',
97 + }}
98 + >
99 + SHARED VERDICT
100 + </div>
101 + </div>
102 +
103 + <div
104 + style={{
105 + display: 'flex',
106 + marginTop: '46px',
107 + maxWidth: '1000px',
108 + fontSize: question.length > 110 ? '38px' : '46px',
109 + fontWeight: 800,
110 + lineHeight: 1.12,
111 + letterSpacing: '-0.035em',
112 + }}
113 + >
114 + {question}
115 + </div>
116 +
117 + <div
118 + style={{
119 + display: 'flex',
120 + marginTop: 'auto',
121 + border: '2px solid rgba(23, 164, 118, 0.2)',
122 + borderRadius: '20px',
123 + background: '#edf9f4',
124 + padding: '22px 26px',
125 + }}
126 + >
127 + <div
128 + style={{
129 + display: 'flex',
130 + height: '36px',
131 + width: '36px',
132 + flexShrink: 0,
133 + alignItems: 'center',
134 + justifyContent: 'center',
135 + borderRadius: '10px',
136 + background: '#17a476',
137 + color: 'white',
138 + fontSize: '20px',
139 + fontWeight: 800,
140 + }}
141 + >
142 + V
143 + </div>
144 + <div style={{ display: 'flex', marginLeft: '16px', flexDirection: 'column' }}>
145 + <div
146 + style={{
147 + display: 'flex',
148 + color: '#117357',
149 + fontSize: '12px',
150 + fontWeight: 800,
151 + letterSpacing: '0.1em',
152 + }}
153 + >
154 + THE VERDICT
155 + </div>
156 + <div
157 + style={{ display: 'flex', marginTop: '7px', fontSize: '19px', lineHeight: 1.35 }}
158 + >
159 + {verdict}
160 + </div>
161 + </div>
162 + </div>
163 +
164 + <div
165 + style={{
166 + display: 'flex',
167 + marginTop: '18px',
168 + justifyContent: 'space-between',
169 + color: '#676d82',
170 + fontSize: '13px',
171 + fontWeight: 700,
172 + }}
173 + >
174 + <div style={{ display: 'flex' }}>{opinionCount} INDEPENDENT OPINIONS</div>
175 + <div style={{ display: 'flex' }}>SEVERAL OPINIONS. ONE CLEAR VERDICT.</div>
176 + </div>
177 + </div>
178 + </div>
179 + ),
180 + size,
181 + );
182 +}
modified src/app/r/[token]/page.tsx +86 −14
@@ -1,31 +1,103 @@
1 -import { GitCompareArrows } from 'lucide-react';
1 +import { ArrowRight, GitCompareArrows, UsersRound } from 'lucide-react';
2 +import type { Metadata } from 'next';
2 3 import Link from 'next/link';
3 4 import { notFound } from 'next/navigation';
4 -import type { Metadata } from 'next';
5 +import { cache } from 'react';
5 6 import { DebateReplay } from '@/components/debate/debate-replay';
7 +import { Badge } from '@/components/ui/badge';
8 +import { Button } from '@/components/ui/button';
6 9 import { loadDebateByShareToken } from '@/db/repositories';
10 +import { truncate } from '@/lib/utils';
7 11
8 12 export const dynamic = 'force-dynamic';
9 13
10 -export const metadata: Metadata = {
11 - title: 'Shared deliberation · Roundtable',
12 - robots: { index: false, follow: false },
13 -};
14 +const getSharedResult = cache(loadDebateByShareToken);
15 +
16 +export async function generateMetadata({
17 + params,
18 +}: {
19 + params: Promise<{ token: string }>;
20 +}): Promise<Metadata> {
21 + const { token } = await params;
22 + const result = await getSharedResult(token);
23 + if (!result) return { title: 'Shared verdict', robots: { index: false, follow: false } };
24 +
25 + const title = `Verdict: ${truncate(result.config.question, 70)}`;
26 + const description = `See how ${result.participants.length} AI models compared this question, challenged each other, and reached a final recommendation.`;
27 + return {
28 + title,
29 + description,
30 + robots: { index: false, follow: false },
31 + openGraph: { title, description, type: 'article' },
32 + twitter: { card: 'summary_large_image', title, description },
33 + };
34 +}
14 35
15 36 export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
16 37 const { token } = await params;
17 - const result = await loadDebateByShareToken(token);
38 + const result = await getSharedResult(token);
18 39 if (!result) notFound();
19 40
20 41 return (
21 - <div className="container max-w-5xl py-8">
22 - <div className="mb-4 flex items-center justify-between">
23 - <Link href="/" className="flex items-center gap-2 text-sm font-medium">
24 - <GitCompareArrows className="h-4 w-4 text-primary" /> Roundtable
25 - </Link>
26 - <span className="text-xs text-muted-foreground">Shared, unlisted deliberation report</span>
42 + <div className="container max-w-6xl py-8 sm:py-12">
43 + <section className="relative overflow-hidden rounded-[1.5rem] border bg-card p-5 sm:p-7">
44 + <div className="absolute right-[-4rem] top-[-5rem] h-48 w-48 rounded-full bg-primary/10 blur-3xl" />
45 + <div className="relative flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
46 + <div className="max-w-3xl">
47 + <Badge variant="secondary" className="mb-4 gap-1.5 rounded-full">
48 + <GitCompareArrows className="h-3.5 w-3.5 text-primary" />
49 + Shared Roundtable verdict
50 + </Badge>
51 + <h1 className="font-display text-2xl font-semibold leading-tight tracking-[-0.03em] sm:text-4xl">
52 + {result.config.question}
53 + </h1>
54 + <div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-muted-foreground">
55 + <span className="flex items-center gap-1.5">
56 + <UsersRound className="h-3.5 w-3.5" />
57 + {result.participants.length} independent opinions
58 + </span>
59 + <span>{result.totals.rounds} review rounds</span>
60 + <span>Original opinions and changes included</span>
61 + </div>
62 + </div>
63 + <Button asChild className="shrink-0 rounded-xl">
64 + <Link href="/debate">
65 + Ask your own question
66 + <ArrowRight className="h-4 w-4" />
67 + </Link>
68 + </Button>
69 + </div>
70 + </section>
71 +
72 + <div className="mt-8">
73 + <DebateReplay result={result} showActions={false} />
27 74 </div>
28 - <DebateReplay result={result} showActions={false} />
75 +
76 + <section className="mt-10 rounded-[1.5rem] border bg-foreground px-6 py-9 text-background sm:px-10">
77 + <div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
78 + <div>
79 + <p className="text-xs font-bold uppercase tracking-[0.16em] text-background/60">
80 + Your turn
81 + </p>
82 + <h2 className="mt-2 font-display text-2xl font-semibold tracking-tight sm:text-3xl">
83 + Agree with the verdict? Put it back on the table.
84 + </h2>
85 + <p className="mt-2 max-w-2xl text-sm leading-relaxed text-background/70">
86 + Ask a new question or run this one again with your own model choices and review rules.
87 + </p>
88 + </div>
89 + <div className="flex shrink-0 flex-wrap gap-2">
90 + <Button asChild variant="secondary">
91 + <Link href={{ pathname: '/debate', query: { q: result.config.question } }}>
92 + Run this question again
93 + </Link>
94 + </Button>
95 + <Button asChild className="bg-background text-foreground hover:bg-background/90">
96 + <Link href="/debate">Ask something else</Link>
97 + </Button>
98 + </div>
99 + </div>
100 + </section>
29 101 </div>
30 102 );
31 103 }
modified src/app/signin/page.tsx +9 −4
@@ -16,8 +16,12 @@export default async function SignInPage() {
16 16 <div className="w-full max-w-sm space-y-6 text-center">
17 17 <GitCompareArrows className="mx-auto h-8 w-8 text-primary" />
18 18 <div className="space-y-2">
19 - <h1 className="font-display text-2xl font-medium tracking-tight">Sign in to Roundtable</h1>
20 - <p className="text-sm text-muted-foreground">Keep a replayable history of every debate you run.</p>
19 + <h1 className="font-display text-2xl font-semibold tracking-tight">
20 + Sign in to Roundtable
21 + </h1>
22 + <p className="text-sm text-muted-foreground">
23 + Keep every question and verdict in one place.
24 + </p>
21 25 </div>
22 26 {isGithubAuthConfigured ? (
23 27 <form
@@ -32,13 +36,14 @@export default async function SignInPage() {
32 36 </form>
33 37 ) : (
34 38 <p className="text-sm text-muted-foreground">
35 - GitHub authentication isn't configured on this deployment. You can still run debates without an account.
39 + GitHub sign-in is not configured on this deployment. You can still ask questions without
40 + an account.
36 41 </p>
37 42 )}
38 43 <p className="text-xs text-muted-foreground">
39 44 Prefer no account?{' '}
40 45 <Link href="/debate" className="text-primary hover:underline">
41 - Run a debate with a session-only key
46 + Ask a question without saving an account
42 47 </Link>
43 48 .
44 49 </p>
modified src/components/api-key-dialog.tsx +25 −14
@@ -70,7 +70,9 @@export function ApiKeyDialog({
70 70 }
71 71 const remaining = data.credits?.remaining;
72 72 toast.success(
73 - remaining != null ? `Key verified - ${formatUsd(remaining)} credits remaining` : 'Key verified and stored',
73 + remaining != null
74 + ? `Key verified - ${formatUsd(remaining)} credits remaining`
75 + : 'Key verified and stored',
74 76 );
75 77 setApiKey('');
76 78 await refresh();
@@ -97,21 +99,22 @@export function ApiKeyDialog({
97 99 <DialogContent>
98 100 <DialogHeader>
99 101 <DialogTitle className="flex items-center gap-2">
100 - <KeyRound className="h-5 w-5 text-primary" /> OpenRouter API key
102 + <KeyRound className="h-5 w-5 text-primary" /> Connect AI models
101 103 </DialogTitle>
102 104 <DialogDescription>
103 - Roundtable is bring-your-own-key - inference is billed to your OpenRouter account, never ours.
105 + Paste an OpenRouter key so Roundtable can ask several AI models for you. Model usage is
106 + billed to your OpenRouter account.
104 107 </DialogDescription>
105 108 </DialogHeader>
106 109
107 110 {status?.mockMode ? (
108 111 <div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
109 112 <Badge variant="warning" className="mb-2">
110 - Mock mode
113 + Demo mode
111 114 </Badge>
112 115 <p>
113 - This deployment runs with <code className="font-mono">MOCK_LLM=1</code>. Debates use a deterministic
114 - offline model - no key required.
116 + This deployment uses offline demo models. You can ask a question without connecting an
117 + account.
115 118 </p>
116 119 </div>
117 120 ) : (
@@ -129,7 +132,7 @@export function ApiKeyDialog({
129 132 )}
130 133
131 134 <div className="space-y-2">
132 - <Label htmlFor="rt-key">Paste your key</Label>
135 + <Label htmlFor="rt-key">OpenRouter key</Label>
133 136 <Input
134 137 id="rt-key"
135 138 type="password"
@@ -139,29 +142,37 @@export function ApiKeyDialog({
139 142 autoComplete="off"
140 143 />
141 144 <p className="text-xs text-muted-foreground">
142 - Validated against OpenRouter before it is stored.
145 + Roundtable checks the key with OpenRouter before using it.
143 146 </p>
144 147 </div>
145 148
146 149 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
147 150 <StorageOption
148 151 active={mode === 'session'}
149 - title="Session only"
150 - desc="Kept in an encrypted HttpOnly cookie for 12h. Nothing saved server-side."
152 + title="This browser session"
153 + desc="Encrypted for 12 hours and not saved to your account."
151 154 onClick={() => setMode('session')}
152 155 />
153 156 <StorageOption
154 157 active={mode === 'save'}
155 158 disabled={!status?.authenticated}
156 - title="Save to account"
157 - desc={status?.authenticated ? 'Encrypted at rest (AES-256-GCM).' : 'Sign in to save a key.'}
159 + title="Save to my account"
160 + desc={
161 + status?.authenticated
162 + ? 'Encrypted and available next time.'
163 + : 'Sign in to save a key.'
164 + }
158 165 onClick={() => status?.authenticated && setMode('save')}
159 166 />
160 167 </div>
161 168
162 169 <Button onClick={submit} disabled={busy || !apiKey.trim()} className="w-full">
163 - {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
164 - Validate & use key
170 + {busy ? (
171 + <Loader2 className="h-4 w-4 animate-spin" />
172 + ) : (
173 + <ShieldCheck className="h-4 w-4" />
174 + )}
175 + Connect models
165 176 </Button>
166 177 </div>
167 178 )}
modified src/components/debate/debate-actions.tsx +49 −13
@@ -5,38 +5,74 @@import Link from 'next/link';
5 5 import { useState } from 'react';
6 6 import { Button } from '@/components/ui/button';
7 7 import { toast } from '@/components/ui/sonner';
8 +import { truncate } from '@/lib/utils';
8 9
9 -export function DebateActions({ debateId }: { debateId: string }) {
10 +export function DebateActions({ debateId, question }: { debateId: string; question: string }) {
10 11 const [shared, setShared] = useState(false);
12 + const [sharing, setSharing] = useState(false);
11 13
12 14 const share = async () => {
15 + setSharing(true);
13 16 try {
14 - const res = await fetch(`/api/debates/${debateId}/share`, { method: 'POST' });
15 - if (!res.ok) throw new Error('share failed');
16 - const { url } = (await res.json()) as { url: string };
17 - await navigator.clipboard.writeText(url).catch(() => {});
17 + const response = await fetch(`/api/debates/${debateId}/share`, { method: 'POST' });
18 + if (!response.ok) throw new Error('share failed');
19 + const { url } = (await response.json()) as { url: string };
20 + const text = `I asked several AI models: "${truncate(question, 120)}" See where they agreed and what changed.`;
21 +
22 + if (navigator.share) {
23 + try {
24 + await navigator.share({ title: 'Roundtable verdict', text, url });
25 + setShared(true);
26 + return;
27 + } catch (error) {
28 + if (error instanceof DOMException && error.name === 'AbortError') return;
29 + }
30 + }
31 +
32 + const copied = await copyText(url);
33 + if (!copied) throw new Error('copy failed');
18 34 setShared(true);
19 - toast.success('Share link copied to clipboard', { description: url });
35 + toast.success('Verdict link copied');
20 36 } catch {
21 - toast.error('Could not create share link');
37 + toast.error('Could not create a share link');
38 + } finally {
39 + setSharing(false);
22 40 }
23 41 };
24 42
25 43 return (
26 - <div className="flex items-center gap-2">
44 + <div className="flex flex-wrap items-center gap-2">
45 + <Button size="sm" onClick={share} disabled={sharing}>
46 + {shared ? <Check className="h-3.5 w-3.5" /> : <Share2 className="h-3.5 w-3.5" />}
47 + {shared ? 'Shared' : sharing ? 'Preparing...' : 'Share verdict'}
48 + </Button>
27 49 <Button variant="outline" size="sm" asChild>
28 50 <Link href={`/debate?from=${debateId}`}>
29 - <RotateCcw className="h-3.5 w-3.5" /> Re-run
51 + <RotateCcw className="h-3.5 w-3.5" /> Ask again
30 52 </Link>
31 53 </Button>
32 54 <Button variant="outline" size="sm" asChild>
33 55 <a href={`/api/debates/${debateId}/export?format=md`} download>
34 - <Download className="h-3.5 w-3.5" /> Export
56 + <Download className="h-3.5 w-3.5" /> Download
35 57 </a>
36 58 </Button>
37 - <Button variant="outline" size="sm" onClick={share}>
38 - {shared ? <Check className="h-3.5 w-3.5" /> : <Share2 className="h-3.5 w-3.5" />} Share
39 - </Button>
40 59 </div>
41 60 );
42 61 }
62 +
63 +async function copyText(value: string): Promise<boolean> {
64 + try {
65 + await navigator.clipboard.writeText(value);
66 + return true;
67 + } catch {
68 + const input = document.createElement('textarea');
69 + input.value = value;
70 + input.style.position = 'fixed';
71 + input.style.opacity = '0';
72 + document.body.appendChild(input);
73 + input.select();
74 + const copied = document.execCommand('copy');
75 + input.remove();
76 + return copied;
77 + }
78 +}
modified src/components/debate/debate-console.tsx +48 −23
@@ -19,11 +19,19 @@import type { AnswerRecord } from '@/core/types';
19 19 import { allCritiques, currentAnswers, type DebateView } from '@/lib/debate-view';
20 20 import { formatUsd } from '@/lib/utils';
21 21
22 -export function DebateConsole({ view, showActions = true }: { view: DebateView; showActions?: boolean }) {
22 +export function DebateConsole({
23 + view,
24 + showActions = true,
25 +}: {
26 + view: DebateView;
27 + showActions?: boolean;
28 +}) {
23 29 const answers = currentAnswers(view);
24 30 const dropped = new Set(view.droppedParticipants);
25 - const lastConvergence = [...view.rounds].reverse().find((r) => r.convergence)?.convergence ?? null;
26 - const finished = view.status === 'completed' || view.status === 'failed' || view.status === 'aborted';
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';
27 35
28 36 return (
29 37 <div className="space-y-6">
@@ -34,8 +42,8 @@export function DebateConsole({ view, showActions = true }: { view: DebateView;
34 42 <div className="flex items-center gap-2">
35 43 <StatusBadge status={view.status} />
36 44 <span className="text-xs text-muted-foreground">
37 - {view.participants.length} models · up to {view.config?.maxRounds ?? '-'} rounds · converge ≥
38 - {view.config?.convergenceThreshold ?? '-'}
45 + {view.participants.length} independent opinions · up to{' '}
46 + {view.config?.maxRounds ?? '-'} review rounds
39 47 </span>
40 48 </div>
41 49 <h1 className="text-balance font-display text-2xl font-medium leading-snug tracking-tight">
@@ -44,7 +52,9 @@export function DebateConsole({ view, showActions = true }: { view: DebateView;
44 52 </div>
45 53 <div className="flex items-center gap-2">
46 54 <CostMeter view={view} />
47 - {showActions && finished && view.debateId && <DebateActions debateId={view.debateId} />}
55 + {showActions && finished && view.debateId && (
56 + <DebateActions debateId={view.debateId} question={view.question} />
57 + )}
48 58 </div>
49 59 </div>
50 60
@@ -58,8 +68,8 @@export function DebateConsole({ view, showActions = true }: { view: DebateView;
58 68 <div className="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 p-3 text-sm">
59 69 <Gavel className="h-4 w-4 text-primary" />
60 70 <span>
61 - You concluded the debate after round {view.gavelStruck.round}; the chairman synthesized the answers as
62 - they stood.
71 + You asked for the verdict after review round {view.gavelStruck.round}. It was prepared
72 + from the opinions completed at that point.
63 73 </span>
64 74 </div>
65 75 )}
@@ -69,8 +79,9 @@export function DebateConsole({ view, showActions = true }: { view: DebateView;
69 79 <PiggyBank className="h-4 w-4 text-amber-500" />
70 80 <span>
71 81 Budget cap reached after round {view.budgetReached.round} (spent{' '}
72 - {formatUsd(view.budgetReached.totalCostUsd)} of a {formatUsd(view.budgetReached.maxCostUsd)} cap);
73 - remaining rounds were skipped and the chairman synthesized what existed.
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.
74 85 </span>
75 86 </div>
76 87 )}
@@ -88,7 +99,10 @@export function DebateConsole({ view, showActions = true }: { view: DebateView;
88 99 provenance={view.provenance}
89 100 />
90 101 {lastConvergence && lastConvergence.disagreements.length > 0 ? (
91 - <Disagreements disagreements={lastConvergence.disagreements} participants={view.participants} />
102 + <Disagreements
103 + disagreements={lastConvergence.disagreements}
104 + participants={view.participants}
105 + />
92 106 ) : (
93 107 <div className="rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-sm">
94 108 <div className="mb-1 flex items-center gap-2 font-medium text-emerald-600 dark:text-emerald-400">
@@ -105,7 +119,9 @@export function DebateConsole({ view, showActions = true }: { view: DebateView;
105 119
106 120 {/* Council panels */}
107 121 <section id="section-council" className="space-y-3">
108 - <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">Council answers</h2>
122 + <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
123 + Independent opinions
124 + </h2>
109 125 <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
110 126 {view.participants.map((p, i) => (
111 127 <ModelPanel
@@ -135,18 +151,24 @@export function DebateConsole({ view, showActions = true }: { view: DebateView;
135 151 {/* Pressure response */}
136 152 {view.rounds.some((r) => r.revisions.length > 0) && (
137 153 <section id="section-spine" className="space-y-3">
138 - <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">Under pressure</h2>
154 + <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
155 + How opinions changed
156 + </h2>
139 157 <div className="rounded-xl border bg-card p-4">
140 158 <SpinePanel participants={view.participants} rounds={view.rounds} />
141 159 </div>
142 - {finished && <MasqueradeNote participants={view.participants} critiques={allCritiques(view)} />}
160 + {finished && (
161 + <MasqueradeNote participants={view.participants} critiques={allCritiques(view)} />
162 + )}
143 163 </section>
144 164 )}
145 165
146 166 {/* Cost breakdown */}
147 167 {finished && (
148 168 <section id="section-cost" className="space-y-3">
149 - <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">Cost breakdown</h2>
169 + <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">
170 + Usage and cost
171 + </h2>
150 172 <div className="rounded-xl border bg-card p-4">
151 173 <CostBreakdown view={view} />
152 174 </div>
@@ -175,10 +197,10 @@function RoundSection({
175 197 >
176 198 <Scale className="h-4 w-4" />
177 199 </span>
178 - <h2 className="text-sm font-semibold">Round {round}</h2>
200 + <h2 className="text-sm font-semibold">Review round {round}</h2>
179 201 {record.convergence && (
180 202 <div className="ml-auto flex items-center gap-2">
181 - <span className="text-xs text-muted-foreground">convergence</span>
203 + <span className="text-xs text-muted-foreground">agreement</span>
182 204 <div className="w-28">
183 205 <Progress value={record.convergence.score} />
184 206 </div>
@@ -192,8 +214,8 @@function RoundSection({
192 214 <div className="p-4">
193 215 <Tabs defaultValue="matrix">
194 216 <TabsList>
195 - <TabsTrigger value="matrix">Critique matrix</TabsTrigger>
196 - <TabsTrigger value="revisions">Revisions & diffs</TabsTrigger>
217 + <TabsTrigger value="matrix">Peer review</TabsTrigger>
218 + <TabsTrigger value="revisions">Answer changes</TabsTrigger>
197 219 </TabsList>
198 220 <TabsContent value="matrix" className="pt-2">
199 221 <CritiqueMatrix participants={view.participants} critiques={record.critiques} />
@@ -243,10 +265,13 @@function answersBeforeRound(view: DebateView, round: number): Record<string, Ans
243 265 }
244 266
245 267 function StatusBadge({ status }: { status: DebateView['status'] }) {
246 - const map: Record<DebateView['status'], { variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning'; label: string }> = {
247 - pending: { variant: 'secondary', label: 'Pending' },
248 - running: { variant: 'warning', label: 'Deliberating' },
249 - completed: { variant: 'success', label: 'Completed' },
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' },
250 275 failed: { variant: 'destructive', label: 'Failed' },
251 276 aborted: { variant: 'secondary', label: 'Aborted' },
252 277 };
modified src/components/debate/disagreements.tsx +9 −3
@@ -17,7 +17,7 @@export function Disagreements({
17 17 return (
18 18 <div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
19 19 <div className="mb-3 flex items-center gap-2 text-sm font-semibold text-amber-600 dark:text-amber-400">
20 - <GitFork className="h-4 w-4" /> Remaining points of disagreement
20 + <GitFork className="h-4 w-4" /> Where views still differ
21 21 </div>
22 22 <div className="space-y-3">
23 23 {disagreements.map((d, i) => (
@@ -32,12 +32,18 @@export function Disagreements({
32 32 <li key={j} className="flex items-start gap-2 text-xs">
33 33 <span
34 34 className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-[9px] font-bold text-white"
35 - style={{ backgroundColor: entry ? participantColor(entry.i) : 'hsl(var(--muted-foreground))' }}
35 + style={{
36 + backgroundColor: entry
37 + ? participantColor(entry.i)
38 + : 'hsl(var(--muted-foreground))',
39 + }}
36 40 >
37 41 {entry ? participantTag(entry.i) : pos.label}
38 42 </span>
39 43 <span>
40 - <span className="font-medium">{entry ? entry.p.displayName : `Response ${pos.label}`}:</span>{' '}
44 + <span className="font-medium">
45 + {entry ? entry.p.displayName : `Response ${pos.label}`}:
46 + </span>{' '}
41 47 <span className="text-muted-foreground">{pos.stance}</span>
42 48 </span>
43 49 </li>
modified src/components/debate/final-answer.tsx +107 −88
@@ -1,14 +1,10 @@
1 1 'use client';
2 2
3 -import { AlertTriangle, Gavel } from 'lucide-react';
3 +import { AlertTriangle, ChevronDown, Gavel } from 'lucide-react';
4 4 import { RichText } from '@/components/debate/rich-text';
5 5 import { Badge } from '@/components/ui/badge';
6 6 import { CopyButton } from '@/components/ui/copy-button';
7 -import {
8 - Tooltip,
9 - TooltipContent,
10 - TooltipTrigger,
11 -} from '@/components/ui/tooltip';
7 +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
12 8 import {
13 9 displayNameForModel,
14 10 type Participant,
@@ -28,122 +24,145 @@export function FinalAnswer({
28 24 chairmanProviderConflict: boolean;
29 25 provenance?: ProvenanceRecord | null;
30 26 }) {
31 - const byId = new Map(participants.map((p, i) => [p.id, { p, i }]));
32 - const unsourcedCount = provenance?.claims.filter((c) => c.unsourced).length ?? 0;
27 + const byId = new Map(
28 + participants.map((participant, index) => [participant.id, { participant, index }]),
29 + );
30 + const untracedCount = provenance?.claims.filter((claim) => claim.unsourced).length ?? 0;
33 31
34 32 return (
35 - <div className="overflow-hidden rounded-xl border-2 border-emerald-500/30 bg-gradient-to-b from-emerald-500/[0.06] to-transparent">
36 - <div className="flex flex-wrap items-center gap-2 border-b border-emerald-500/20 p-4">
37 - <span
38 - className="flex h-8 w-8 items-center justify-center rounded-lg text-white"
39 - style={{ backgroundColor: 'hsl(var(--stage-synthesis))' }}
40 - >
33 + <div className="overflow-hidden rounded-[1.25rem] border-2 border-emerald-500/25 bg-card shadow-lg shadow-emerald-500/5">
34 + <div className="flex flex-wrap items-center gap-3 border-b border-emerald-500/20 bg-emerald-500/[0.055] p-4 sm:p-5">
35 + <span className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500 text-white shadow-sm">
41 36 <Gavel className="h-4 w-4" />
42 37 </span>
43 38 <div>
44 - <div className="text-sm font-semibold">Chairman synthesis</div>
45 - <div className="font-mono text-[11px] text-muted-foreground">{synthesis.model}</div>
39 + <div className="text-[10px] font-bold uppercase tracking-[0.16em] text-emerald-700 dark:text-emerald-300">
40 + Final recommendation
41 + </div>
42 + <div className="mt-0.5 font-display text-lg font-semibold">The verdict</div>
43 + <div className="text-[11px] text-muted-foreground">
44 + Combined by {displayNameForModel(synthesis.model)}
45 + </div>
46 46 </div>
47 47 <div className="ml-auto flex items-center gap-2">
48 48 {chairmanProviderConflict && (
49 49 <Tooltip>
50 50 <TooltipTrigger asChild>
51 51 <Badge variant="warning" className="cursor-help gap-1">
52 - <AlertTriangle className="h-3 w-3" /> provider overlap
52 + <AlertTriangle className="h-3 w-3" /> model mix
53 53 </Badge>
54 54 </TooltipTrigger>
55 55 <TooltipContent className="max-w-xs">
56 - The chairman shares a provider family with a council member, a self-preference risk. Answers are shown
57 - to the chairman anonymized to mitigate it, but consider a chairman from an independent provider.
56 + The same AI provider appears in the opinion group and the verdict model. Names were
57 + hidden during review to reduce bias.
58 58 </TooltipContent>
59 59 </Tooltip>
60 60 )}
61 - <CopyButton text={synthesis.finalAnswer} label="Copy answer" />
61 + <CopyButton text={synthesis.finalAnswer} label="Copy verdict" />
62 62 </div>
63 63 </div>
64 64
65 - <div className="p-5">
66 - <RichText text={synthesis.finalAnswer} className="text-[15px]" />
65 + <div className="p-5 sm:p-6">
66 + <RichText text={synthesis.finalAnswer} className="text-[15px] leading-relaxed" />
67 67
68 68 {provenance && provenance.claims.length > 0 && (
69 - <div className="mt-5 rounded-lg border bg-card p-4">
70 - <div className="mb-1 flex flex-wrap items-center gap-2">
71 - <span className="text-sm font-semibold">Claim check</span>
72 - {unsourcedCount > 0 ? (
73 - <Badge variant="warning">{unsourcedCount} chairman addition{unsourcedCount > 1 ? 's' : ''}</Badge>
69 + <details className="group mt-6 rounded-xl border bg-background/55">
70 + <summary className="flex cursor-pointer list-none items-center gap-2 p-4 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
71 + How this verdict was checked
72 + {untracedCount > 0 ? (
73 + <Badge variant="warning">{untracedCount} new in verdict</Badge>
74 74 ) : (
75 - <Badge variant="success">all claims sourced</Badge>
75 + <Badge variant="success">Every claim traced</Badge>
76 76 )}
77 + <ChevronDown className="ml-auto h-4 w-4 text-muted-foreground transition-transform group-open:rotate-180" />
78 + </summary>
79 + <div className="border-t p-4">
80 + <p className="mb-3 text-xs leading-relaxed text-muted-foreground">
81 + Each important claim is matched to the opinions that support or challenge it. A new
82 + claim is one that appeared only when the final verdict was written.
83 + </p>
84 + <ul className="space-y-2.5">
85 + {provenance.claims.map((claim, claimIndex) => (
86 + <li key={claimIndex} className="flex items-start gap-2 text-xs">
87 + <span className="mt-1 flex shrink-0 items-center gap-0.5">
88 + {claim.unsourced ? (
89 + <span
90 + className="h-2 w-2 rounded-full bg-amber-500"
91 + title="New in the final verdict"
92 + />
93 + ) : (
94 + claim.supportedBy.map((support) => {
95 + const entry = byId.get(support.participantId);
96 + const index = entry?.index ?? 0;
97 + return (
98 + <span
99 + key={support.participantId}
100 + className="flex h-4 w-4 items-center justify-center rounded text-[9px] font-bold text-white"
101 + style={{ backgroundColor: participantColor(index) }}
102 + title={`Supported by ${entry?.participant.displayName ?? support.model}`}
103 + >
104 + {participantTag(index)}
105 + </span>
106 + );
107 + })
108 + )}
109 + </span>
110 + <span
111 + className={claim.unsourced ? 'text-amber-600 dark:text-amber-400' : undefined}
112 + >
113 + {claim.text}
114 + {claim.unsourced && (
115 + <span className="ml-1 font-medium">(new in the verdict)</span>
116 + )}
117 + {claim.contestedBy.length > 0 && (
118 + <span className="text-muted-foreground">
119 + {' '}
120 + · challenged by{' '}
121 + {claim.contestedBy
122 + .map(
123 + (challenge) =>
124 + byId.get(challenge.participantId)?.participant.displayName ??
125 + displayNameForModel(challenge.model),
126 + )
127 + .join(', ')}
128 + </span>
129 + )}
130 + </span>
131 + </li>
132 + ))}
133 + </ul>
77 134 </div>
78 - <p className="mb-3 text-xs text-muted-foreground">
79 - Each substantive claim in the final answer, traced back to the council by {provenance.model}. A claim
80 - nobody argued is flagged: that is where synthesis hallucination hides.
81 - </p>
82 - <ul className="space-y-2">
83 - {provenance.claims.map((claim, i) => (
84 - <li key={i} className="flex items-start gap-2 text-xs">
85 - <span className="mt-1 flex shrink-0 items-center gap-0.5">
86 - {claim.unsourced ? (
87 - <span className="h-2 w-2 rounded-full bg-amber-500" title="No council member made this claim" />
88 - ) : (
89 - claim.supportedBy.map((m) => {
90 - const entry = byId.get(m.participantId);
91 - const idx = entry?.i ?? 0;
92 - return (
93 - <span
94 - key={m.participantId}
95 - className="flex h-4 w-4 items-center justify-center rounded text-[9px] font-bold text-white"
96 - style={{ backgroundColor: participantColor(idx) }}
97 - title={`Supported by ${entry?.p.displayName ?? m.model}`}
98 - >
99 - {participantTag(idx)}
100 - </span>
101 - );
102 - })
103 - )}
104 - </span>
105 - <span className={claim.unsourced ? 'text-amber-600 dark:text-amber-400' : undefined}>
106 - {claim.text}
107 - {claim.unsourced && <span className="ml-1 font-medium">(chairman&apos;s own addition)</span>}
108 - {claim.contestedBy.length > 0 && (
109 - <span className="text-muted-foreground">
110 - {' '}
111 - · contested by{' '}
112 - {claim.contestedBy
113 - .map((m) => byId.get(m.participantId)?.p.displayName ?? displayNameForModel(m.model))
114 - .join(', ')}
115 - </span>
116 - )}
117 - </span>
118 - </li>
119 - ))}
120 - </ul>
121 - </div>
135 + </details>
122 136 )}
123 137
124 138 {synthesis.dissent.length > 0 && (
125 - <div className="mt-5 rounded-lg border bg-card p-4">
126 - <div className="mb-2 text-sm font-semibold">Dissent report</div>
127 - <div className="space-y-2.5">
128 - {synthesis.dissent.map((d, i) => (
129 - <div key={i}>
130 - <div className="text-sm font-medium">{d.topic}</div>
131 - <ul className="mt-1 space-y-1">
132 - {d.positions.map((pos, j) => {
133 - const entry = byId.get(pos.participantId);
134 - const idx = entry?.i ?? 0;
135 - const name = entry?.p.displayName ?? displayNameForModel(pos.model);
139 + <details className="group mt-3 rounded-xl border bg-background/55">
140 + <summary className="flex cursor-pointer list-none items-center gap-2 p-4 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
141 + Where opinions still differ
142 + <Badge variant="secondary">{synthesis.dissent.length}</Badge>
143 + <ChevronDown className="ml-auto h-4 w-4 text-muted-foreground transition-transform group-open:rotate-180" />
144 + </summary>
145 + <div className="space-y-4 border-t p-4">
146 + {synthesis.dissent.map((dissent, dissentIndex) => (
147 + <div key={dissentIndex}>
148 + <div className="text-sm font-medium">{dissent.topic}</div>
149 + <ul className="mt-2 space-y-2">
150 + {dissent.positions.map((position, positionIndex) => {
151 + const entry = byId.get(position.participantId);
152 + const index = entry?.index ?? 0;
153 + const name =
154 + entry?.participant.displayName ?? displayNameForModel(position.model);
136 155 return (
137 - <li key={j} className="flex items-start gap-2 text-xs">
156 + <li key={positionIndex} className="flex items-start gap-2 text-xs">
138 157 <span
139 158 className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-[9px] font-bold text-white"
140 - style={{ backgroundColor: participantColor(idx) }}
159 + style={{ backgroundColor: participantColor(index) }}
141 160 >
142 - {participantTag(idx)}
161 + {participantTag(index)}
143 162 </span>
144 163 <span>
145 164 <span className="font-medium">{name}:</span>{' '}
146 - <span className="text-muted-foreground">{pos.position}</span>
165 + <span className="text-muted-foreground">{position.position}</span>
147 166 </span>
148 167 </li>
149 168 );
@@ -152,7 +171,7 @@export function FinalAnswer({
152 171 </div>
153 172 ))}
154 173 </div>
155 - </div>
174 + </details>
156 175 )}
157 176 </div>
158 177 </div>
modified src/components/debate/new-debate.tsx +344 −227
@@ -1,13 +1,26 @@
1 1 'use client';
2 2
3 -import { AlertTriangle, Gavel, KeyRound, Loader2, Play, RotateCcw, Save, Square } from 'lucide-react';
3 +import {
4 + AlertTriangle,
5 + ChevronDown,
6 + Gavel,
7 + KeyRound,
8 + Loader2,
9 + Play,
10 + RotateCcw,
11 + Save,
12 + Settings2,
13 + Square,
14 + UsersRound,
15 +} from 'lucide-react';
4 16 import { useEffect, useMemo, useState } from 'react';
5 17 import { ApiKeyDialog } from '@/components/api-key-dialog';
6 18 import { DebateConsole } from '@/components/debate/debate-console';
7 19 import { ModelCombobox } from '@/components/debate/model-combobox';
8 20 import { ModelPicker } from '@/components/debate/model-picker';
9 21 import { Badge } from '@/components/ui/badge';
10 22 import { Button } from '@/components/ui/button';
23 +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
11 24 import { Input } from '@/components/ui/input';
12 25 import { Label } from '@/components/ui/label';
13 26 import { Slider } from '@/components/ui/slider';
@@ -17,6 +30,7 @@import type { DebateResult } from '@/core/types';
17 30 import { useDebateStream } from '@/hooks/use-debate-stream';
18 31 import { useModels } from '@/hooks/use-models';
19 32 import { estimateDebateCostUsd } from '@/lib/cost-estimate';
33 +import { QUESTION_EXAMPLES } from '@/lib/question-examples';
20 34 import { formatUsd } from '@/lib/utils';
21 35
22 36 interface Preset {
@@ -30,13 +44,17 @@interface Preset {
30 44 temperature: number;
31 45 }
32 46
33 -const EXAMPLE = 'Should a small startup build on a monolith or microservices? Give a decisive recommendation.';
34 -
35 -export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
47 +export function NewDebate({
48 + fromDebateId,
49 + initialQuestion = '',
50 +}: {
51 + fromDebateId?: string;
52 + initialQuestion?: string;
53 +}) {
36 54 const { view, phase, errorMsg, errorCode, start, cancel, reset } = useDebateStream();
37 - const { models } = useModels();
55 + const { models, loading: modelsLoading, error: modelsError } = useModels();
38 56
39 - const [question, setQuestion] = useState('');
57 + const [question, setQuestion] = useState(initialQuestion.slice(0, 8000));
40 58 const [council, setCouncil] = useState<string[]>([]);
41 59 const [chairman, setChairman] = useState('');
42 60 const [convergenceModel, setConvergenceModel] = useState(DEFAULT_CONVERGENCE_MODEL);
@@ -49,13 +67,13 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
49 67 const [keyDialogOpen, setKeyDialogOpen] = useState(false);
50 68 const [needsKey, setNeedsKey] = useState(false);
51 69 const [prefilled, setPrefilled] = useState(false);
70 + const [advancedOpen, setAdvancedOpen] = useState(false);
52 71
53 - // Prefill the whole form from a previous debate (/debate?from=<id>).
54 72 useEffect(() => {
55 73 if (!fromDebateId || prefilled) return;
56 74 let cancelled = false;
57 75 fetch(`/api/debates/${fromDebateId}`)
58 - .then((r) => (r.ok ? (r.json() as Promise<DebateResult>) : null))
76 + .then((response) => (response.ok ? (response.json() as Promise<DebateResult>) : null))
59 77 .then((result) => {
60 78 if (!result || cancelled) return;
61 79 setQuestion(result.config.question);
@@ -66,67 +84,73 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
66 84 setThreshold(result.config.convergenceThreshold);
67 85 setTemperature(result.config.temperature);
68 86 setPrefilled(true);
69 - toast.message('Loaded question and council from the previous debate');
87 + toast.message('Loaded the question and setup from your previous roundtable');
70 88 })
71 89 .catch(() => {});
72 90 return () => {
73 91 cancelled = true;
74 92 };
75 93 }, [fromDebateId, prefilled]);
76 94
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 95 useEffect(() => {
80 96 if (errorCode !== 'NO_KEY') return;
81 97 setNeedsKey(true);
82 98 setKeyDialogOpen(true);
83 99 reset();
84 100 }, [errorCode, reset]);
85 101
86 - // Seed a sensible default council + chairman once the catalog arrives.
87 102 useEffect(() => {
88 103 if (models.length === 0 || council.length > 0) return;
89 - const want = [
104 + const preferred = [
90 105 'openai/gpt-4o',
91 106 'anthropic/claude-3.5-sonnet',
92 107 'google/gemini-pro-1.5',
93 - ].filter((id) => models.some((m) => m.id === id));
94 - const seed = want.length >= 3 ? want : models.slice(0, 3).map((m) => m.id);
95 - setCouncil(seed);
108 + ].filter((id) => models.some((model) => model.id === id));
109 + const selected =
110 + preferred.length >= 3 ? preferred : models.slice(0, 3).map((model) => model.id);
111 + setCouncil(selected);
96 112 if (!chairman) {
97 - const chair = models.find((m) => m.id === 'x-ai/grok-2-1212') ?? models.find((m) => !seed.includes(m.id));
98 - if (chair) setChairman(chair.id);
113 + const verdictModel =
114 + models.find((model) => model.id === 'x-ai/grok-2-1212') ??
115 + models.find((model) => !selected.includes(model.id));
116 + if (verdictModel) setChairman(verdictModel.id);
99 117 }
100 118 // eslint-disable-next-line react-hooks/exhaustive-deps
101 119 }, [models]);
102 120
103 121 useEffect(() => {
104 122 fetch('/api/presets')
105 - .then((r) => r.json())
106 - .then((d: { presets: Preset[] }) => setPresets(d.presets ?? []))
123 + .then((response) => response.json())
124 + .then((data: { presets: Preset[] }) => setPresets(data.presets ?? []))
107 125 .catch(() => {});
108 126 }, []);
109 127
110 - const providerConflict = chairman && council.length > 0 && chairmanSharesProvider(chairman, council);
128 + const providerConflict =
129 + chairman && council.length > 0 && chairmanSharesProvider(chairman, council);
111 130 const maxCostUsd = Number.parseFloat(maxCost);
112 131 const capValid = maxCost.trim() === '' || (Number.isFinite(maxCostUsd) && maxCostUsd > 0);
113 - const startBlocker =
114 - question.trim().length < 3
115 - ? 'Write a question to get started'
116 - : council.length < 3
117 - ? `Pick at least 3 council models (${council.length} selected)`
118 - : council.length > 6
119 - ? 'A council holds at most 6 models'
120 - : !chairman
121 - ? 'Pick a chairman to write the final verdict'
122 - : !capValid
123 - ? 'Fix the budget cap (positive amount or empty)'
124 - : null;
132 +
133 + let startBlocker: string | null = null;
134 + if (question.trim().length < 3) startBlocker = 'Write a question to get started';
135 + else if (question.trim().length > 8000) startBlocker = 'Shorten the question to 8,000 characters';
136 + else if (council.length < 3) {
137 + if (modelsLoading) startBlocker = 'Choosing your models...';
138 + else if (modelsError) startBlocker = 'Open settings and retry the model list';
139 + else startBlocker = `Choose at least 3 models (${council.length} selected)`;
140 + } else if (council.length > 6) startBlocker = 'Choose no more than 6 models';
141 + else if (!chairman) startBlocker = 'Choose a model to combine the final verdict';
142 + else if (!capValid) startBlocker = 'Fix the spending limit';
143 +
125 144 const canStart = startBlocker === null;
126 145 const running = phase === 'connecting' || phase === 'streaming';
127 -
128 146 const priceMap = useMemo(
129 - () => new Map(models.map((m) => [m.id, { prompt: m.promptPrice, completion: m.completionPrice }])),
147 + () =>
148 + new Map(
149 + models.map((model) => [
150 + model.id,
151 + { prompt: model.promptPrice, completion: model.completionPrice },
152 + ]),
153 + ),
130 154 [models],
131 155 );
132 156 const estimate = useMemo(
@@ -143,19 +167,19 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
143 167 [canStart, council, chairman, convergenceModel, maxRounds, priceMap],
144 168 );
145 169
146 - const applyPreset = (p: Preset) => {
147 - setCouncil(p.models);
148 - setChairman(p.chairmanModel);
149 - setConvergenceModel(p.convergenceModel ?? DEFAULT_CONVERGENCE_MODEL);
150 - setMaxRounds(p.maxRounds);
151 - setThreshold(p.convergenceThreshold);
152 - setTemperature(p.temperature);
153 - toast.success(`Loaded preset "${p.name}"`);
170 + const applyPreset = (preset: Preset) => {
171 + setCouncil(preset.models);
172 + setChairman(preset.chairmanModel);
173 + setConvergenceModel(preset.convergenceModel ?? DEFAULT_CONVERGENCE_MODEL);
174 + setMaxRounds(preset.maxRounds);
175 + setThreshold(preset.convergenceThreshold);
176 + setTemperature(preset.temperature);
177 + toast.success(`Loaded setup "${preset.name}"`);
154 178 };
155 179
156 180 const savePreset = async () => {
157 181 if (!presetName.trim()) return;
158 - const res = await fetch('/api/presets', {
182 + const response = await fetch('/api/presets', {
159 183 method: 'POST',
160 184 headers: { 'Content-Type': 'application/json' },
161 185 body: JSON.stringify({
@@ -168,23 +192,23 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
168 192 temperature,
169 193 }),
170 194 });
171 - if (res.ok) {
172 - const { preset } = (await res.json()) as { preset: Preset };
173 - setPresets((p) => [preset, ...p.filter((x) => x.id !== preset.id)]);
195 + if (response.ok) {
196 + const { preset } = (await response.json()) as { preset: Preset };
197 + setPresets((current) => [preset, ...current.filter((item) => item.id !== preset.id)]);
174 198 setPresetName('');
175 - toast.success('Preset saved');
176 - } else if (res.status === 401) {
177 - toast.error('Sign in to save presets');
199 + toast.success('Setup saved');
200 + } else if (response.status === 401) {
201 + toast.error('Sign in to save setups');
178 202 } else {
179 - toast.error('Could not save preset');
203 + toast.error('Could not save setup');
180 204 }
181 205 };
182 206
183 207 const cancelDebateRun = async () => {
184 208 if (!view.debateId) return;
185 209 try {
186 210 await fetch(`/api/debates/${view.debateId}/cancel`, { method: 'POST' });
187 - toast.message('Cancelling debate...');
211 + toast.message('Cancelling the roundtable...');
188 212 } catch {
189 213 toast.error('Could not cancel');
190 214 }
@@ -194,13 +218,14 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
194 218 if (!view.debateId) return;
195 219 try {
196 220 await fetch(`/api/debates/${view.debateId}/gavel`, { method: 'POST' });
197 - toast.message('Concluding: the chairman will synthesize the answers as they stand.');
221 + toast.message('Preparing a verdict from the opinions completed so far');
198 222 } catch {
199 - toast.error('Could not conclude the debate');
223 + toast.error('Could not prepare the verdict');
200 224 }
201 225 };
202 226
203 - const launch = () =>
227 + const launch = () => {
228 + if (!canStart) return;
204 229 start({
205 230 question: question.trim(),
206 231 models: council,
@@ -212,180 +237,290 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
212 237 perModelTimeoutMs: 90_000,
213 238 ...(capValid && maxCost.trim() !== '' ? { maxCostUsd } : {}),
214 239 });
240 + };
215 241
216 242 if (phase === 'idle') {
217 243 return (
218 - <div className="mx-auto max-w-3xl">
219 - <div className="space-y-3 pb-10">
220 - <p className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground">new debate</p>
221 - <h1 className="font-display text-3xl font-medium tracking-tight sm:text-4xl">
222 - Put it to the <em className="text-primary">council</em>.
244 + <div className="mx-auto max-w-4xl">
245 + <div className="mx-auto max-w-2xl pb-9 text-center">
246 + <Badge variant="secondary" className="mb-4 rounded-full px-3 py-1">
247 + New question
248 + </Badge>
249 + <h1 className="font-display text-4xl font-semibold tracking-[-0.04em] sm:text-5xl">
250 + What do you need to decide?
223 251 </h1>
224 - <p className="max-w-xl text-sm leading-relaxed text-muted-foreground">
225 - A council of models answers your question, critiques each other blind, and revises. A chairman writes the
226 - verdict.
252 + <p className="mx-auto mt-4 max-w-xl leading-relaxed text-muted-foreground">
253 + Describe the choice, what matters to you, and any limits. Roundtable will compare
254 + independent opinions and give you one checked verdict.
227 255 </p>
228 256 </div>
229 257
230 - <FormSection number="01" label="Question">
258 + <section className="rounded-[1.5rem] border bg-card p-4 shadow-xl shadow-primary/5 sm:p-6">
259 + <Label htmlFor="debate-question" className="text-sm font-semibold">
260 + Your question
261 + </Label>
231 262 <textarea
263 + id="debate-question"
232 264 value={question}
233 - onChange={(e) => setQuestion(e.target.value)}
234 - onKeyDown={(e) => {
235 - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canStart) {
236 - e.preventDefault();
265 + onChange={(event) => setQuestion(event.target.value)}
266 + onKeyDown={(event) => {
267 + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && canStart) {
268 + event.preventDefault();
237 269 launch();
238 270 }
239 271 }}
240 - placeholder={EXAMPLE}
241 - rows={3}
242 - aria-label="Debate question"
243 - className="flex w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
272 + placeholder="Include your options, priorities, and constraints..."
273 + rows={5}
274 + autoFocus={!initialQuestion}
275 + className="mt-3 flex w-full resize-y rounded-xl border border-input bg-background/70 px-4 py-3 text-base leading-relaxed shadow-sm placeholder:text-muted-foreground/65 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
244 276 />
245 - <div className="mt-2 flex items-center justify-between">
246 - <button className="text-xs text-primary hover:underline" onClick={() => setQuestion(EXAMPLE)}>
247 - Use an example question
248 - </button>
277 + <div className="mt-2 flex items-center justify-between gap-3">
278 + <span className="text-xs text-muted-foreground">{question.length} / 8,000</span>
249 279 <span className="text-xs text-muted-foreground">
250 280 <kbd className="rounded border bg-muted px-1 font-mono text-[10px]">Ctrl</kbd> +{' '}
251 - <kbd className="rounded border bg-muted px-1 font-mono text-[10px]">Enter</kbd> to start
281 + <kbd className="rounded border bg-muted px-1 font-mono text-[10px]">Enter</kbd> to
282 + start
252 283 </span>
253 284 </div>
254 - </FormSection>
255 -
256 - <FormSection number="02" label="Council">
257 - <ModelPicker council={council} onChange={setCouncil} />
258 - </FormSection>
259 -
260 - <FormSection number="03" label="Rules">
261 - <div className="space-y-5">
262 - <div className="grid gap-4 sm:grid-cols-2">
263 - <div className="space-y-1.5">
264 - <Label>Chairman</Label>
265 - <p className="text-xs text-muted-foreground">
266 - Reads every model&apos;s answer and writes the final verdict. Best chosen from outside the council.
267 - </p>
268 - <ModelCombobox value={chairman} onChange={setChairman} placeholder="Independent model preferred" />
269 - {providerConflict && (
270 - <p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
271 - <AlertTriangle className="h-3.5 w-3.5" /> Shares a provider family with a council member.
272 - </p>
273 - )}
274 - </div>
275 - <div className="space-y-1.5">
276 - <Label>Agreement checker</Label>
277 - <p className="text-xs text-muted-foreground">
278 - A fast, cheap model that scores how much the council still disagrees after each round.
279 - </p>
280 - <ModelCombobox value={convergenceModel} onChange={setConvergenceModel} placeholder="A fast, cheap model" />
281 - </div>
282 - </div>
283 285
284 - <SliderRow
285 - label="Debate rounds"
286 - value={maxRounds}
287 - min={1}
288 - max={5}
289 - step={1}
290 - onChange={setMaxRounds}
291 - display={String(maxRounds)}
292 - hint="How many times the models critique and revise each other. More rounds refine the answer but cost more."
293 - />
294 - <SliderRow
295 - label="Stop-early threshold"
296 - value={threshold}
297 - min={50}
298 - max={100}
299 - step={1}
300 - onChange={setThreshold}
301 - display={`${threshold}/100`}
302 - hint="End the debate early once the council's agreement reaches this score, instead of using every round."
303 - />
304 - <SliderRow
305 - label="Creativity"
306 - value={temperature}
307 - min={0}
308 - max={1.5}
309 - step={0.1}
310 - onChange={setTemperature}
311 - display={temperature.toFixed(1)}
312 - hint="Low keeps answers focused and consistent. High makes them more varied and creative."
313 - />
314 -
315 - <div className="space-y-1.5">
316 - <Label htmlFor="rt-budget">Budget cap (optional)</Label>
317 - <div className="flex items-center gap-2">
318 - <span className="text-sm text-muted-foreground">$</span>
319 - <Input
320 - id="rt-budget"
321 - inputMode="decimal"
322 - placeholder="no cap"
323 - value={maxCost}
324 - onChange={(e) => setMaxCost(e.target.value)}
325 - className="h-8 max-w-[8rem]"
326 - />
327 - </div>
328 - <p className="text-xs text-muted-foreground">
329 - If spend crosses this, remaining rounds are skipped and the chairman synthesizes what exists, so you
330 - still get an answer for the money already spent.
331 - </p>
332 - {!capValid && (
333 - <p className="text-xs text-destructive">Enter a positive dollar amount, or leave empty for no cap.</p>
334 - )}
286 + <div className="mt-5 border-t pt-4">
287 + <p className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
288 + Need a starting point?
289 + </p>
290 + <div className="grid gap-2 sm:grid-cols-2">
291 + {QUESTION_EXAMPLES.map((example) => (
292 + <button
293 + key={example.short}
294 + type="button"
295 + onClick={() => setQuestion(example.question)}
296 + className="rounded-xl border bg-background/60 p-3 text-left transition-colors hover:border-primary/35 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
297 + >
298 + <span className="block text-[10px] font-bold uppercase tracking-[0.14em] text-primary">
299 + {example.category}
300 + </span>
301 + <span className="mt-1 block text-sm font-semibold">{example.short}</span>
302 + </button>
303 + ))}
335 304 </div>
336 -
337 - {presets.length > 0 && (
338 - <div className="space-y-1.5">
339 - <Label>Presets</Label>
340 - <div className="flex flex-wrap gap-2">
341 - {presets.map((p) => (
342 - <Button key={p.id} variant="secondary" size="sm" onClick={() => applyPreset(p)}>
343 - {p.name}
344 - </Button>
345 - ))}
346 - </div>
347 - </div>
305 + </div>
306 + </section>
307 +
308 + <div className="mt-5 flex flex-col gap-4 rounded-2xl border bg-card/70 p-4 sm:flex-row sm:items-center">
309 + <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
310 + {modelsLoading && council.length === 0 ? (
311 + <Loader2 className="h-5 w-5 animate-spin" />
312 + ) : (
313 + <UsersRound className="h-5 w-5" />
348 314 )}
315 + </span>
316 + <div className="min-w-0 flex-1">
317 + <p className="font-semibold">Your roundtable is ready</p>
318 + <p className="mt-0.5 text-sm text-muted-foreground">
319 + {council.length >= 3
320 + ? `${council.length} independent models, up to ${maxRounds} review ${maxRounds === 1 ? 'round' : 'rounds'}, and one final verdict.`
321 + : 'Choosing a balanced set of models for your question.'}
322 + </p>
323 + </div>
324 + {canStart && (
325 + <div className="shrink-0 text-xs text-muted-foreground sm:text-right">
326 + Estimated maximum
327 + <strong className="ml-1 text-foreground">{formatUsd(estimate)}</strong>
328 + </div>
329 + )}
330 + </div>
349 331
350 - <div className="flex items-center gap-2">
351 - <Input
352 - value={presetName}
353 - onChange={(e) => setPresetName(e.target.value)}
354 - placeholder="Save this council as..."
355 - className="h-8 max-w-xs"
332 + <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-4">
333 + <CollapsibleTrigger asChild>
334 + <button
335 + type="button"
336 + className="flex w-full items-center gap-3 rounded-xl border bg-background/70 px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
337 + >
338 + <Settings2 className="h-4 w-4 text-muted-foreground" />
339 + Customize models and rules
340 + <span className="ml-auto text-xs font-normal text-muted-foreground">Optional</span>
341 + <ChevronDown
342 + className={`h-4 w-4 text-muted-foreground transition-transform ${advancedOpen ? 'rotate-180' : ''}`}
356 343 />
357 - <Button variant="outline" size="sm" onClick={savePreset} disabled={!presetName.trim()}>
358 - <Save className="h-3.5 w-3.5" /> Save
359 - </Button>
344 + </button>
345 + </CollapsibleTrigger>
346 + <CollapsibleContent>
347 + <div className="mt-3 space-y-8 rounded-2xl border bg-card p-5 sm:p-7">
348 + <section>
349 + <h2 className="font-display text-xl font-semibold">Choose the opinions</h2>
350 + <p className="mb-4 mt-1 text-sm text-muted-foreground">
351 + Pick 3 to 6 models. Different providers usually give you a wider range of views.
352 + </p>
353 + <ModelPicker council={council} onChange={setCouncil} />
354 + </section>
355 +
356 + <section className="border-t pt-7">
357 + <h2 className="font-display text-xl font-semibold">Set the review rules</h2>
358 + <p className="mb-5 mt-1 text-sm text-muted-foreground">
359 + The defaults work for most questions. Adjust them when you need tighter control
360 + over cost or style.
361 + </p>
362 + <div className="space-y-6">
363 + <div className="grid gap-5 sm:grid-cols-2">
364 + <div className="space-y-1.5">
365 + <Label>Verdict model</Label>
366 + <p className="text-xs text-muted-foreground">
367 + Reads every opinion and combines the final recommendation.
368 + </p>
369 + <ModelCombobox
370 + value={chairman}
371 + onChange={setChairman}
372 + placeholder="Choose a verdict model"
373 + />
374 + {providerConflict && (
375 + <p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
376 + <AlertTriangle className="h-3.5 w-3.5" /> This provider is also
377 + represented in the group.
378 + </p>
379 + )}
380 + </div>
381 + <div className="space-y-1.5">
382 + <Label>Agreement checker</Label>
383 + <p className="text-xs text-muted-foreground">
384 + Measures whether another review round is still useful.
385 + </p>
386 + <ModelCombobox
387 + value={convergenceModel}
388 + onChange={setConvergenceModel}
389 + placeholder="Choose an agreement model"
390 + />
391 + </div>
392 + </div>
393 +
394 + <SliderRow
395 + label="Review rounds"
396 + value={maxRounds}
397 + min={1}
398 + max={5}
399 + step={1}
400 + onChange={setMaxRounds}
401 + display={String(maxRounds)}
402 + hint="More rounds let the models challenge and improve their answers again, but cost more."
403 + />
404 + <SliderRow
405 + label="Stop when agreement reaches"
406 + value={threshold}
407 + min={50}
408 + max={100}
409 + step={1}
410 + onChange={setThreshold}
411 + display={`${threshold}/100`}
412 + hint="Roundtable stops early when the opinions have already reached this level of agreement."
413 + />
414 + <SliderRow
415 + label="Answer variety"
416 + value={temperature}
417 + min={0}
418 + max={1.5}
419 + step={0.1}
420 + onChange={setTemperature}
421 + display={temperature.toFixed(1)}
422 + hint="Lower values stay focused. Higher values explore more varied approaches."
423 + />
424 +
425 + <div className="space-y-1.5">
426 + <Label htmlFor="rt-budget">Spending limit (optional)</Label>
427 + <div className="flex items-center gap-2">
428 + <span className="text-sm text-muted-foreground">$</span>
429 + <Input
430 + id="rt-budget"
431 + inputMode="decimal"
432 + placeholder="No limit"
433 + value={maxCost}
434 + onChange={(event) => setMaxCost(event.target.value)}
435 + className="h-9 max-w-[9rem]"
436 + />
437 + </div>
438 + <p className="text-xs text-muted-foreground">
439 + If the limit is reached, remaining rounds are skipped and you still receive a
440 + verdict from the work completed so far.
441 + </p>
442 + {!capValid && (
443 + <p className="text-xs text-destructive">
444 + Enter a positive dollar amount or leave this empty.
445 + </p>
446 + )}
447 + </div>
448 +
449 + {presets.length > 0 && (
450 + <div className="space-y-2">
451 + <Label>Saved setups</Label>
452 + <div className="flex flex-wrap gap-2">
453 + {presets.map((preset) => (
454 + <Button
455 + key={preset.id}
456 + variant="secondary"
457 + size="sm"
458 + onClick={() => applyPreset(preset)}
459 + >
460 + {preset.name}
461 + </Button>
462 + ))}
463 + </div>
464 + </div>
465 + )}
466 +
467 + <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
468 + <Input
469 + value={presetName}
470 + onChange={(event) => setPresetName(event.target.value)}
471 + placeholder="Name this setup"
472 + className="h-9 max-w-xs"
473 + />
474 + <Button
475 + variant="outline"
476 + size="sm"
477 + onClick={savePreset}
478 + disabled={!presetName.trim()}
479 + >
480 + <Save className="h-3.5 w-3.5" /> Save setup
481 + </Button>
482 + </div>
483 + </div>
484 + </section>
360 485 </div>
361 - </div>
362 - </FormSection>
486 + </CollapsibleContent>
487 + </Collapsible>
363 488
364 - <div className="space-y-4 border-t pt-6">
489 + <div className="mt-6 space-y-4">
365 490 {needsKey && (
366 - <div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm">
491 + <div className="flex flex-wrap items-center gap-2 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-sm">
367 492 <KeyRound className="h-4 w-4 text-amber-500" />
368 - <span>Running a real debate needs an OpenRouter key. Add one, then press Start again.</span>
369 - <Button variant="outline" size="sm" className="ml-auto" onClick={() => setKeyDialogOpen(true)}>
370 - Add key
493 + <span>Connect your models once, then your question will stay ready here.</span>
494 + <Button
495 + variant="outline"
496 + size="sm"
497 + className="ml-auto"
498 + onClick={() => setKeyDialogOpen(true)}
499 + >
500 + Connect models
371 501 </Button>
372 502 </div>
373 503 )}
374 504
375 505 {errorMsg && (
376 - <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
506 + <div className="flex items-center gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
377 507 <AlertTriangle className="h-4 w-4" /> {errorMsg}
378 508 </div>
379 509 )}
380 510
381 - <div className="sticky bottom-4 flex items-center justify-end gap-3 pb-2">
382 - <span className="rounded-md bg-background/80 px-2.5 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur">
511 + <div className="sticky bottom-4 flex flex-col gap-3 rounded-2xl border bg-background/90 p-3 shadow-xl backdrop-blur sm:flex-row sm:items-center sm:justify-between">
512 + <span className="px-1 text-xs text-muted-foreground">
383 513 {canStart
384 - ? `~${formatUsd(estimate)} est. · up to ${maxRounds} ${maxRounds === 1 ? 'round' : 'rounds'}`
514 + ? `Estimated maximum ${formatUsd(estimate)}. You will see live progress.`
385 515 : startBlocker}
386 516 </span>
387 - <Button size="lg" disabled={!canStart} onClick={launch} className="shadow-lg">
388 - <Play className="h-4 w-4" /> Start debate
517 + <Button
518 + size="lg"
519 + disabled={!canStart}
520 + onClick={launch}
521 + className="rounded-xl px-7 shadow-lg"
522 + >
523 + <Play className="h-4 w-4" /> Get my verdict
389 524 </Button>
390 525 </div>
391 526 </div>
@@ -404,18 +539,18 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
404 539
405 540 return (
406 541 <div className="space-y-4">
407 - <div className="flex items-center justify-between">
542 + <div className="flex flex-wrap items-center justify-between gap-3">
408 543 <div className="flex items-center gap-2 text-sm text-muted-foreground">
409 544 {running ? (
410 545 <>
411 - <Loader2 className="h-4 w-4 animate-spin text-primary" /> Debate in progress - you can leave; it
412 - continues server-side.
546 + <Loader2 className="h-4 w-4 animate-spin text-primary" /> Roundtable is working. You
547 + can leave this page and it will continue.
413 548 </>
414 549 ) : (
415 - <Badge variant="secondary">Finished</Badge>
550 + <Badge variant="success">Verdict ready</Badge>
416 551 )}
417 552 </div>
418 - <div className="flex gap-2">
553 + <div className="flex flex-wrap gap-2">
419 554 {running && (
420 555 <>
421 556 <Button variant="outline" size="sm" onClick={cancel}>
@@ -426,23 +561,23 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
426 561 size="sm"
427 562 onClick={concludeNow}
428 563 disabled={!view.debateId || Boolean(view.gavelStruck)}
429 - title="Skip remaining rounds and have the chairman synthesize now"
564 + title="Skip remaining reviews and prepare the final verdict now"
430 565 >
431 - <Gavel className="h-3.5 w-3.5" /> Conclude now
566 + <Gavel className="h-3.5 w-3.5" /> Get verdict now
432 567 </Button>
433 568 <Button variant="destructive" size="sm" onClick={cancelDebateRun}>
434 - <Square className="h-3.5 w-3.5" /> Cancel debate
569 + <Square className="h-3.5 w-3.5" /> Cancel
435 570 </Button>
436 571 </>
437 572 )}
438 573 <Button variant="outline" size="sm" onClick={reset}>
439 - <RotateCcw className="h-3.5 w-3.5" /> New debate
574 + <RotateCcw className="h-3.5 w-3.5" /> New question
440 575 </Button>
441 576 </div>
442 577 </div>
443 578
444 579 {errorMsg && (
445 - <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
580 + <div className="flex items-center gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
446 581 <AlertTriangle className="h-4 w-4" /> {errorMsg}
447 582 </div>
448 583 )}
@@ -452,30 +587,6 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
452 587 );
453 588 }
454 589
455 -/**
456 - * Editorial form section: numbered mono label on a rail, content beside it,
457 - * hairline rule above. Replaces the stacked-card look.
458 - */
459 -function FormSection({
460 - number,
461 - label,
462 - children,
463 -}: {
464 - number: string;
465 - label: string;
466 - children: React.ReactNode;
467 -}) {
468 - return (
469 - <section className="grid gap-4 border-t py-8 sm:grid-cols-[9rem_1fr] sm:gap-8">
470 - <div className="flex items-baseline gap-2 sm:block">
471 - <span className="font-mono text-xs text-muted-foreground/60">{number}</span>
472 - <h2 className="font-mono text-xs uppercase tracking-[0.22em] text-muted-foreground sm:mt-1.5">{label}</h2>
473 - </div>
474 - <div className="min-w-0">{children}</div>
475 - </section>
476 - );
477 -}
478 -
479 590 function SliderRow({
480 591 label,
481 592 value,
@@ -491,7 +602,7 @@function SliderRow({
491 602 min: number;
492 603 max: number;
493 604 step: number;
494 - onChange: (v: number) => void;
605 + onChange: (value: number) => void;
495 606 display: string;
496 607 hint?: string;
497 608 }) {
@@ -501,7 +612,13 @@function SliderRow({
501 612 <Label>{label}</Label>
502 613 <span className="font-mono text-xs text-muted-foreground">{display}</span>
503 614 </div>
504 - <Slider value={[value]} min={min} max={max} step={step} onValueChange={(v) => onChange(v[0]!)} />
615 + <Slider
616 + value={[value]}
617 + min={min}
618 + max={max}
619 + step={step}
620 + onValueChange={(values) => onChange(values[0]!)}
621 + />
505 622 {hint && <p className="text-xs leading-relaxed text-muted-foreground">{hint}</p>}
506 623 </div>
507 624 );
modified src/components/debate/stage-timeline.tsx +16 −10
@@ -55,7 +55,9 @@export function StageTimeline({ view }: { view: DebateView }) {
55 55 </span>
56 56 <span className="leading-tight">
57 57 <span className="block text-xs font-medium">{node.label}</span>
58 - {node.sub && <span className="block text-[11px] text-muted-foreground">{node.sub}</span>}
58 + {node.sub && (
59 + <span className="block text-[11px] text-muted-foreground">{node.sub}</span>
60 + )}
59 61 </span>
60 62 </button>
61 63 {i < nodes.length - 1 && <span className="mx-1 h-px w-5 bg-border" />}
@@ -73,8 +75,8 @@function buildNodes(view: DebateView): TimelineNode[] {
73 75
74 76 nodes.push({
75 77 key: 'answers',
76 - label: 'Round 0',
77 - sub: 'Answers',
78 + label: 'Opinions',
79 + sub: 'Independent',
78 80 icon: <MessagesSquare className="h-4 w-4" />,
79 81 status: answersActive ? 'active' : answersDone ? 'done' : 'pending',
80 82 accent: 'var(--stage-answer)',
@@ -87,8 +89,8 @@function buildNodes(view: DebateView): TimelineNode[] {
87 89 const isActiveRound = view.activeStage?.round === r;
88 90 nodes.push({
89 91 key: `crit-${r}`,
90 - label: `Round ${r}`,
91 - sub: 'Critique',
92 + label: `Review ${r}`,
93 + sub: 'Challenge',
92 94 icon: <Scale className="h-4 w-4" />,
93 95 status: round?.critiques.length
94 96 ? 'done'
@@ -100,8 +102,8 @@function buildNodes(view: DebateView): TimelineNode[] {
100 102 });
101 103 nodes.push({
102 104 key: `rev-${r}`,
103 - label: `Round ${r}`,
104 - sub: 'Revision',
105 + label: `Review ${r}`,
106 + sub: 'Improve',
105 107 icon: <PencilLine className="h-4 w-4" />,
106 108 status: round?.revisions.length
107 109 ? 'done'
@@ -115,10 +117,14 @@function buildNodes(view: DebateView): TimelineNode[] {
115 117
116 118 nodes.push({
117 119 key: 'synthesis',
118 - label: 'Synthesis',
119 - sub: 'Chairman',
120 + label: 'Verdict',
121 + sub: 'Combine',
120 122 icon: <Gavel className="h-4 w-4" />,
121 - status: view.synthesis ? 'done' : view.activeStage?.stage === 'synthesis' ? 'active' : 'pending',
123 + status: view.synthesis
124 + ? 'done'
125 + : view.activeStage?.stage === 'synthesis'
126 + ? 'active'
127 + : 'pending',
122 128 accent: 'var(--stage-synthesis)',
123 129 anchor: 'section-final',
124 130 });
added src/components/decision-starter.tsx +71 −0
@@ -0,0 +1,71 @@
1 +'use client';
2 +
3 +import { ArrowRight } from 'lucide-react';
4 +import { useRouter } from 'next/navigation';
5 +import { useState } from 'react';
6 +import { Button } from '@/components/ui/button';
7 +import { QUESTION_EXAMPLES } from '@/lib/question-examples';
8 +
9 +export function DecisionStarter() {
10 + const router = useRouter();
11 + const [question, setQuestion] = useState('');
12 +
13 + const submit = () => {
14 + const value = question.trim();
15 + if (value.length < 3) return;
16 + router.push(`/debate?q=${encodeURIComponent(value)}`);
17 + };
18 +
19 + return (
20 + <div>
21 + <form
22 + onSubmit={(event) => {
23 + event.preventDefault();
24 + submit();
25 + }}
26 + className="rounded-[1.35rem] border bg-card p-2 shadow-xl shadow-primary/10 ring-1 ring-foreground/[0.03]"
27 + >
28 + <label htmlFor="home-question" className="sr-only">
29 + What do you want help deciding?
30 + </label>
31 + <textarea
32 + id="home-question"
33 + value={question}
34 + onChange={(event) => setQuestion(event.target.value)}
35 + rows={3}
36 + maxLength={1600}
37 + placeholder="What do you want help deciding?"
38 + className="block w-full resize-none bg-transparent px-3 py-3 text-base leading-relaxed outline-none placeholder:text-muted-foreground/70"
39 + />
40 + <div className="flex items-center justify-between gap-3 border-t px-2 pt-2">
41 + <span className="hidden text-xs text-muted-foreground sm:block">
42 + Add your options, priorities, and limits.
43 + </span>
44 + <Button
45 + type="submit"
46 + size="lg"
47 + disabled={question.trim().length < 3}
48 + className="ml-auto rounded-xl"
49 + >
50 + Ask the table
51 + <ArrowRight className="h-4 w-4" />
52 + </Button>
53 + </div>
54 + </form>
55 +
56 + <div className="mt-4 flex flex-wrap gap-2">
57 + <span className="py-1.5 text-xs font-medium text-muted-foreground">Try one:</span>
58 + {QUESTION_EXAMPLES.slice(0, 3).map((example) => (
59 + <button
60 + key={example.short}
61 + type="button"
62 + onClick={() => setQuestion(example.question)}
63 + className="rounded-full border bg-background/70 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
64 + >
65 + {example.short}
66 + </button>
67 + ))}
68 + </div>
69 + </div>
70 + );
71 +}
modified src/components/history-list.tsx +11 −6
@@ -26,7 +26,7 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) {
26 26 setDeleting(id);
27 27 const res = await fetch(`/api/debates/${id}`, { method: 'DELETE' });
28 28 if (res.ok) {
29 - toast.success('Debate deleted');
29 + toast.success('Question deleted');
30 30 router.refresh();
31 31 } else {
32 32 toast.error('Could not delete');
@@ -48,7 +48,9 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) {
48 48
49 49 {filtered.length === 0 ? (
50 50 <div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
51 - {debates.length === 0 ? 'No debates yet - start one to see it here.' : 'No debates match your search.'}
51 + {debates.length === 0
52 + ? 'No questions yet. Ask one to see it here.'
53 + : 'No questions match your search.'}
52 54 </div>
53 55 ) : (
54 56 <ul className="space-y-2">
@@ -64,13 +66,16 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) {
64 66 </div>
65 67 <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
66 68 <span className="flex items-center gap-1">
67 - <MessageSquare className="h-3 w-3" /> {d.models.length} models · {d.roundsCompleted} rounds
69 + <MessageSquare className="h-3 w-3" /> {d.models.length} models ·{' '}
70 + {d.roundsCompleted} rounds
68 71 </span>
69 72 <span className="flex items-center gap-1">
70 73 <Coins className="h-3 w-3" /> {formatUsd(d.totalCostUsd)}
71 74 </span>
72 75 <span>{formatRelativeTime(d.createdAt)}</span>
73 - <span className="hidden truncate sm:inline">{d.models.map(displayNameForModel).join(' · ')}</span>
76 + <span className="hidden truncate sm:inline">
77 + {d.models.map(displayNameForModel).join(' · ')}
78 + </span>
74 79 </div>
75 80 </Link>
76 81 {d.shareToken && <Badge variant="secondary">shared</Badge>}
@@ -79,7 +84,7 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) {
79 84 size="icon"
80 85 className="opacity-0 transition-opacity group-hover:opacity-100"
81 86 asChild
82 - aria-label="Re-run with this question and council"
87 + aria-label="Ask this question again with the same models"
83 88 >
84 89 <Link href={`/debate?from=${d.id}`}>
85 90 <RotateCcw className="h-4 w-4" />
@@ -91,7 +96,7 @@export function HistoryList({ debates }: { debates: DebateSummary[] }) {
91 96 className="opacity-0 transition-opacity group-hover:opacity-100"
92 97 disabled={deleting === d.id}
93 98 onClick={() => remove(d.id)}
94 - aria-label="Delete debate"
99 + aria-label="Delete question"
95 100 >
96 101 <Trash2 className="h-4 w-4" />
97 102 </Button>
modified src/components/key-button.tsx +15 −4
@@ -8,12 +8,17 @@import { cn } from '@/lib/utils';
8 8
9 9 export function KeyButton() {
10 10 const [open, setOpen] = useState(false);
11 - const [state, setState] = useState<{ hasKey: boolean; mock: boolean }>({ hasKey: false, mock: false });
11 + const [state, setState] = useState<{ hasKey: boolean; mock: boolean }>({
12 + hasKey: false,
13 + mock: false,
14 + });
12 15
13 16 const load = () =>
14 17 fetch('/api/keys')
15 18 .then((r) => r.json())
16 - .then((s: { hasKey: boolean; mockMode: boolean }) => setState({ hasKey: s.hasKey, mock: s.mockMode }))
19 + .then((s: { hasKey: boolean; mockMode: boolean }) =>
20 + setState({ hasKey: s.hasKey, mock: s.mockMode }),
21 + )
17 22 .catch(() => {});
18 23
19 24 useEffect(() => {
@@ -26,11 +31,17 @@export function KeyButton() {
26 31 <span
27 32 className={cn(
28 33 'h-2 w-2 rounded-full',
29 - state.mock ? 'bg-amber-500' : state.hasKey ? 'bg-emerald-500' : 'bg-muted-foreground/40',
34 + state.mock
35 + ? 'bg-amber-500'
36 + : state.hasKey
37 + ? 'bg-emerald-500'
38 + : 'bg-muted-foreground/40',
30 39 )}
31 40 />
32 41 <KeyRound className="h-4 w-4" />
33 - <span className="hidden sm:inline">{state.mock ? 'Mock' : state.hasKey ? 'Key set' : 'Add key'}</span>
42 + <span className="hidden sm:inline">
43 + {state.mock ? 'Demo models' : state.hasKey ? 'Models connected' : 'Connect models'}
44 + </span>
34 45 </Button>
35 46 <ApiKeyDialog open={open} onOpenChange={setOpen} onChanged={load} />
36 47 </>
modified src/components/landing-background.tsx +5 −34
@@ -1,40 +1,11 @@
1 -/**
2 - * Atmospheric backdrop for the landing page: soft glows in the four debate-stage
3 - * colors (answer / critique / revision / synthesis) over a fine film grain, so
4 - * the palette is tied to the actual product rather than a generic gradient.
5 - */
6 1 export function LandingBackground() {
7 2 return (
8 3 <div aria-hidden className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
9 - <Glow className="-left-[12%] -top-[15%] h-[46rem] w-[46rem]" color="var(--stage-answer)" opacity={0.18} />
10 - <Glow className="-right-[10%] top-[2%] h-[40rem] w-[40rem]" color="var(--stage-revision)" opacity={0.15} delay="-6s" />
11 - <Glow className="left-[28%] top-[34%] h-[38rem] w-[38rem]" color="var(--stage-synthesis)" opacity={0.11} delay="-12s" />
12 - <Glow className="right-[18%] top-[48%] h-[30rem] w-[30rem]" color="var(--stage-critique)" opacity={0.1} delay="-3s" />
13 - <div className="absolute inset-0 bg-grain opacity-[0.35] mix-blend-soft-light dark:opacity-[0.4]" />
14 - <div className="absolute inset-x-0 bottom-0 h-72 bg-gradient-to-b from-transparent to-background" />
4 + <div className="absolute inset-x-0 top-0 h-[52rem] bg-[radial-gradient(circle_at_78%_22%,hsl(var(--primary)/0.14),transparent_34%),radial-gradient(circle_at_12%_8%,hsl(var(--stage-answer)/0.08),transparent_28%)]" />
5 + <div className="decision-grid absolute inset-x-0 top-0 h-[42rem] opacity-35 [mask-image:linear-gradient(to_bottom,black,transparent)] dark:opacity-20" />
6 + <div className="absolute right-[-16rem] top-[-11rem] h-[44rem] w-[44rem] rounded-full border border-primary/10" />
7 + <div className="absolute right-[-11rem] top-[-6rem] h-[34rem] w-[34rem] rounded-full border border-primary/10" />
8 + <div className="absolute right-[-6rem] top-[-1rem] h-[24rem] w-[24rem] rounded-full border border-primary/10" />
15 9 </div>
16 10 );
17 11 }
18 -
19 -function Glow({
20 - className,
21 - color,
22 - opacity,
23 - delay = '0s',
24 -}: {
25 - className: string;
26 - color: string;
27 - opacity: number;
28 - delay?: string;
29 -}) {
30 - return (
31 - <div
32 - className={`animate-drift absolute rounded-full blur-[130px] ${className}`}
33 - style={{
34 - background: `radial-gradient(circle, hsl(${color}) 0%, transparent 70%)`,
35 - opacity,
36 - animationDelay: delay,
37 - }}
38 - />
39 - );
40 -}
modified src/components/landing-hero.tsx +163 −132
@@ -1,154 +1,185 @@
1 -'use client';
2 -
3 -import { ArrowRight, Pause, Play } from 'lucide-react';
1 +import { ArrowUpRight, Check, Gavel, UsersRound } from 'lucide-react';
4 2 import Link from 'next/link';
5 -import { useEffect, useState } from 'react';
6 -import { CouncilNodes } from '@/components/debate/council-nodes';
7 -import { DebateConsole } from '@/components/debate/debate-console';
8 -import { Button } from '@/components/ui/button';
3 +import { DecisionStarter } from '@/components/decision-starter';
4 +import { Badge } from '@/components/ui/badge';
9 5 import type { DebateResult } from '@/core/types';
10 -import { useDebatePlayback } from '@/hooks/use-debate-playback';
11 6 import { truncate } from '@/lib/utils';
12 7
13 -/**
14 - * The landing hero: a real recorded debate, front and center. It loads showing
15 - * the finished verdict - nothing moving - so a visitor gets the payoff at once.
16 - * Pressing play replays the whole deliberation live (streaming tokens, critique,
17 - * revision, synthesis); it settles back on the answer and never loops on its own.
18 - */
19 -export function LandingHero({ result }: { result: DebateResult }) {
20 - const { view, state, progress, play, pause, restart, skipToEnd, setSpeed } = useDebatePlayback(result);
21 - const [hasPlayed, setHasPlayed] = useState(false);
22 -
23 - // Load on the final answer, ready to read. We only animate on demand.
24 - useEffect(() => {
25 - setSpeed(2);
26 - skipToEnd();
27 - // eslint-disable-next-line react-hooks/exhaustive-deps
28 - }, []);
8 +const FALLBACK_QUESTION = 'Should I take the higher-paying startup job or stay in my stable role?';
9 +const FALLBACK_OPINIONS = [
10 + {
11 + name: 'Growth view',
12 + content:
13 + 'Take the startup role if the manager is strong and you have enough savings for the added risk.',
14 + },
15 + {
16 + name: 'Risk view',
17 + content:
18 + 'Stay unless the new role gives you specific skills and ownership that your current job cannot offer.',
19 + },
20 + {
21 + name: 'Values view',
22 + content:
23 + 'Choose the role that fits the life you want for the next two years, not salary alone.',
24 + },
25 +];
26 +const FALLBACK_VERDICT =
27 + 'Take the startup role only if it clearly improves your manager, ownership, and learning. Keep the stable role if the move is mainly about salary.';
29 28
30 - const isLive = state === 'playing';
31 - const watch = () => {
32 - setHasPlayed(true);
33 - restart();
34 - };
29 +export function LandingHero({ result }: { result?: DebateResult | null }) {
30 + const question = result?.config.question ?? FALLBACK_QUESTION;
31 + const opinions =
32 + result?.initialAnswers.slice(0, 3).map((answer) => ({
33 + name:
34 + result.participants.find((participant) => participant.id === answer.participantId)
35 + ?.displayName ?? 'AI view',
36 + content: compact(answer.content, 150),
37 + })) ?? FALLBACK_OPINIONS;
38 + const verdict = compact(result?.synthesis?.finalAnswer ?? FALLBACK_VERDICT, 320);
35 39
36 40 return (
37 - <section className="container py-10 md:py-14">
38 - <div className="mx-auto mb-9 max-w-2xl text-center">
39 - <div
40 - className="reveal mb-5 inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground"
41 - style={{ animationDelay: '0ms' }}
42 - >
43 - {isLive ? (
44 - <>
45 - <span className="h-1.5 w-1.5 animate-pulse-subtle rounded-full bg-emerald-500" />
46 - the council is in session
47 - </>
48 - ) : (
49 - <>
50 - <span className="h-1.5 w-1.5 rounded-full bg-primary/60" />
51 - one real debate, start to finish
52 - </>
53 - )}
54 - </div>
55 - <h1
56 - className="reveal font-display text-4xl font-medium leading-[1.04] tracking-[-0.01em] sm:text-[4.25rem]"
57 - style={{ animationDelay: '80ms' }}
58 - >
59 - Watch models <em className="text-primary">argue</em> their way to a better answer.
60 - </h1>
61 - <p
62 - className="reveal mx-auto mt-5 max-w-xl text-pretty text-lg leading-relaxed text-muted-foreground"
63 - style={{ animationDelay: '160ms' }}
64 - >
65 - Below is the verdict from a real debate. Press play to watch how the council got there: they answer, critique
66 - each other blind, and revise, before a chairman calls it.
67 - </p>
68 - <div
69 - className="reveal mt-7 flex flex-wrap items-center justify-center gap-3"
70 - style={{ animationDelay: '240ms' }}
71 - >
72 - <Button size="lg" asChild className="group">
73 - <Link href="/debate">
74 - Start a debate
75 - <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
41 + <section className="container relative py-14 sm:py-20 lg:py-24">
42 + <div className="grid items-center gap-14 lg:grid-cols-[minmax(0,1.02fr)_minmax(28rem,0.98fr)] lg:gap-16">
43 + <div className="relative z-10">
44 + <Badge variant="secondary" className="mb-6 gap-2 rounded-full px-3 py-1.5 font-medium">
45 + <UsersRound className="h-3.5 w-3.5 text-primary" />
46 + One question, several independent opinions
47 + </Badge>
48 + <h1 className="max-w-3xl font-display text-5xl font-semibold leading-[0.98] tracking-[-0.045em] sm:text-6xl lg:text-7xl">
49 + Ask once. Get the <span className="text-primary">whole picture.</span>
50 + </h1>
51 + <p className="mt-6 max-w-xl text-lg leading-relaxed text-muted-foreground sm:text-xl">
52 + Roundtable asks leading AI models separately, lets them challenge each other, then gives
53 + you one clear recommendation and shows where they disagree.
54 + </p>
55 + <div className="mt-8 max-w-2xl">
56 + <DecisionStarter />
57 + </div>
58 + <div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-muted-foreground">
59 + <span className="flex items-center gap-1.5">
60 + <Check className="h-3.5 w-3.5 text-emerald-500" /> Smart defaults included
61 + </span>
62 + <span className="flex items-center gap-1.5">
63 + <Check className="h-3.5 w-3.5 text-emerald-500" /> Every opinion stays visible
64 + </span>
65 + <Link
66 + href="/demo"
67 + className="font-medium text-foreground underline-offset-4 hover:underline"
68 + >
69 + See example verdicts
76 70 </Link>
77 - </Button>
78 - <Button size="lg" variant="ghost" asChild>
79 - <Link href="/demo">Browse the recordings</Link>
80 - </Button>
71 + </div>
81 72 </div>
82 - </div>
83 73
84 - <div className="reveal mb-6 flex justify-center" style={{ animationDelay: '320ms' }}>
85 - <CouncilNodes participants={result.participants} chairmanModel={result.config.chairmanModel} />
74 + <VerdictStack
75 + question={question}
76 + opinions={opinions}
77 + verdict={verdict}
78 + demoId={result?.debateId}
79 + />
86 80 </div>
81 + </section>
82 + );
83 +}
87 84
88 - <div className="reveal relative mx-auto max-w-5xl" style={{ animationDelay: '400ms' }}>
89 - <div className="pointer-events-none absolute -inset-x-4 -top-4 bottom-4 -z-10 rounded-[2.5rem] bg-primary/[0.07] blur-3xl" />
90 - <div className="relative overflow-hidden rounded-2xl border bg-card shadow-2xl ring-1 ring-black/5 dark:ring-white/10">
91 - <div className="absolute inset-x-0 top-0 z-20 h-0.5 bg-transparent">
92 - <div
93 - className="h-full bg-gradient-to-r from-primary to-violet-400 transition-all duration-300"
94 - style={{ width: `${progress}%` }}
95 - />
85 +function VerdictStack({
86 + question,
87 + opinions,
88 + verdict,
89 + demoId,
90 +}: {
91 + question: string;
92 + opinions: { name: string; content: string }[];
93 + verdict: string;
94 + demoId?: string;
95 +}) {
96 + return (
97 + <div className="relative mx-auto w-full max-w-xl">
98 + <div className="absolute -inset-8 -z-10 rounded-full bg-primary/10 blur-3xl" />
99 + <div className="rounded-[1.75rem] border bg-card/95 p-4 shadow-2xl shadow-foreground/10 ring-1 ring-foreground/[0.04] sm:p-5">
100 + <div className="flex items-center justify-between gap-4 border-b pb-4">
101 + <div className="min-w-0">
102 + <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
103 + Question on the table
104 + </p>
105 + <p className="mt-1.5 text-sm font-semibold leading-snug">{truncate(question, 120)}</p>
96 106 </div>
97 - <div className="flex items-center gap-3 border-b bg-muted/30 px-4 py-2.5">
98 - <span className="flex shrink-0 gap-1.5">
99 - <span className="h-2.5 w-2.5 rounded-full bg-red-400/70" />
100 - <span className="h-2.5 w-2.5 rounded-full bg-amber-400/70" />
101 - <span className="h-2.5 w-2.5 rounded-full bg-emerald-400/70" />
102 - </span>
103 - <div className="mx-auto hidden max-w-md flex-1 items-center justify-center rounded-md bg-background/60 px-3 py-1 sm:flex">
104 - <span className="truncate font-mono text-[11px] text-muted-foreground">
105 - {truncate(view.question || result.config.question, 64)}
106 - </span>
107 - </div>
108 - {isLive ? (
109 - <span className="ml-auto flex shrink-0 items-center gap-1.5 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-500 sm:ml-0">
110 - <span className="h-1.5 w-1.5 animate-pulse-subtle rounded-full bg-emerald-500" />
111 - live
112 - </span>
113 - ) : (
114 - <span className="ml-auto flex shrink-0 items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground sm:ml-0">
115 - verdict
107 + <div
108 + className="flex shrink-0 -space-x-2"
109 + aria-label={`${opinions.length} independent opinions`}
110 + >
111 + {opinions.map((opinion, index) => (
112 + <span
113 + key={opinion.name}
114 + className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card text-[11px] font-bold text-white"
115 + style={{
116 + backgroundColor: `hsl(var(--stage-${['answer', 'critique', 'revision'][index] ?? 'answer'}))`,
117 + }}
118 + >
119 + {index + 1}
116 120 </span>
117 - )}
118 - </div>
119 -
120 - <div className="p-4 md:p-6">
121 - <DebateConsole view={view} showActions={false} />
121 + ))}
122 122 </div>
123 123 </div>
124 124
125 - <div className="mt-5 flex flex-wrap items-center justify-center gap-3">
126 - {state === 'playing' ? (
127 - <Button variant="secondary" size="sm" onClick={pause}>
128 - <Pause className="h-4 w-4" /> Pause
129 - </Button>
130 - ) : state === 'paused' ? (
131 - <Button size="sm" onClick={play}>
132 - <Play className="h-4 w-4" /> Resume
133 - </Button>
134 - ) : (
135 - <Button size="sm" onClick={watch} className="group">
136 - <Play className="h-4 w-4 transition-transform group-hover:scale-110" />
137 - {hasPlayed ? 'Replay the debate' : 'Watch it unfold'}
138 - </Button>
139 - )}
140 -
141 - {(state === 'playing' || state === 'paused') && (
142 - <Button variant="ghost" size="sm" onClick={skipToEnd}>
143 - Skip to the answer
144 - </Button>
145 - )}
125 + <div className="relative mt-4 space-y-2.5">
126 + {opinions.map((opinion, index) => (
127 + <div
128 + key={`${opinion.name}-${index}`}
129 + className="rounded-xl border bg-background/80 p-3.5 transition-transform duration-300 hover:translate-x-1"
130 + style={{
131 + marginLeft: `${index * 10}px`,
132 + marginRight: `${(opinions.length - index - 1) * 10}px`,
133 + }}
134 + >
135 + <div className="flex items-center gap-2">
136 + <span
137 + className="h-2 w-2 rounded-full"
138 + style={{
139 + backgroundColor: `hsl(var(--stage-${['answer', 'critique', 'revision'][index] ?? 'answer'}))`,
140 + }}
141 + />
142 + <span className="text-xs font-semibold">{opinion.name}</span>
143 + <span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
144 + independent
145 + </span>
146 + </div>
147 + <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
148 + {opinion.content}
149 + </p>
150 + </div>
151 + ))}
152 + </div>
146 153
147 - <Button asChild variant="outline" size="sm">
148 - <Link href={`/demo/${result.debateId}`}>Open the full replay</Link>
149 - </Button>
154 + <div className="relative mt-4 overflow-hidden rounded-2xl border border-emerald-500/25 bg-emerald-500/[0.07] p-5">
155 + <div className="absolute right-0 top-0 h-28 w-28 -translate-y-8 translate-x-8 rounded-full bg-emerald-400/15 blur-2xl" />
156 + <div className="relative flex items-start gap-3">
157 + <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-emerald-500 text-white shadow-sm">
158 + <Gavel className="h-4 w-4" />
159 + </span>
160 + <div>
161 + <p className="text-xs font-bold uppercase tracking-[0.14em] text-emerald-700 dark:text-emerald-300">
162 + The verdict
163 + </p>
164 + <p className="mt-2 text-sm leading-relaxed text-foreground/90">{verdict}</p>
165 + </div>
166 + </div>
150 167 </div>
168 +
169 + {demoId && (
170 + <Link
171 + href={`/demo/${demoId}`}
172 + className="mt-4 flex items-center justify-center gap-1.5 rounded-xl py-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
173 + >
174 + Open this real example
175 + <ArrowUpRight className="h-3.5 w-3.5" />
176 + </Link>
177 + )}
151 178 </div>
152 - </section>
179 + </div>
153 180 );
154 181 }
182 +
183 +function compact(value: string, length: number): string {
184 + return truncate(value.replace(/\s+/g, ' ').trim(), length);
185 +}
modified src/components/site-header.tsx +13 −9
@@ -11,17 +11,21 @@export async function SiteHeader() {
11 11 const user = session?.user;
12 12
13 13 return (
14 - <header className="sticky top-0 z-40 w-full border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
15 - <div className="container flex h-14 items-center gap-4">
16 - <Link href="/" className="flex items-center gap-2">
17 - <GitCompareArrows className="h-5 w-5 text-primary" />
18 - <span className="font-display text-[17px] font-semibold tracking-tight">Roundtable</span>
14 + <header className="sticky top-0 z-40 w-full border-b bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70">
15 + <div className="container flex h-16 items-center gap-4">
16 + <Link href="/" className="flex items-center gap-2.5">
17 + <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
18 + <GitCompareArrows className="h-4 w-4" />
19 + </span>
20 + <span className="font-display text-[17px] font-semibold tracking-[-0.025em]">
21 + Roundtable
22 + </span>
19 23 </Link>
20 24
21 - <nav className="ml-4 hidden items-center gap-1 text-sm md:flex">
22 - <NavLink href="/debate">New debate</NavLink>
23 - <NavLink href="/demo">Demo</NavLink>
24 - {user && <NavLink href="/history">History</NavLink>}
25 + <nav className="ml-5 hidden items-center gap-1 text-sm md:flex">
26 + <NavLink href="/debate">Ask a question</NavLink>
27 + <NavLink href="/demo">Examples</NavLink>
28 + {user && <NavLink href="/history">Past questions</NavLink>}
25 29 </nav>
26 30
27 31 <div className="ml-auto flex items-center gap-1">
added src/lib/question-examples.ts +26 −0
@@ -0,0 +1,26 @@
1 +export const QUESTION_EXAMPLES = [
2 + {
3 + category: 'Career',
4 + short: 'Compare two job offers',
5 + question:
6 + 'I have two job offers. One pays more at a risky startup, while the other offers stability and a stronger manager. Help me decide what to prioritize.',
7 + },
8 + {
9 + category: 'Buying',
10 + short: 'Choose the right laptop',
11 + question:
12 + 'Help me choose between a MacBook Air and a Windows ultrabook for travel, light creative work, and a budget of $1,500.',
13 + },
14 + {
15 + category: 'Ideas',
16 + short: 'Stress-test an idea',
17 + question:
18 + 'Stress-test this business idea and give me a clear go or no-go recommendation: a weekly meal-planning service for busy families.',
19 + },
20 + {
21 + category: 'Work',
22 + short: 'Make a team decision',
23 + question:
24 + 'My small team is deciding whether to build this feature now or improve onboarding first. Compare the tradeoffs and recommend one.',
25 + },
26 +] as const;
modified tailwind.config.ts +1 −1
@@ -84,7 +84,7 @@const config: Config = {
84 84 fontFamily: {
85 85 sans: ['var(--font-sans)', 'ui-sans-serif', 'system-ui', 'sans-serif'],
86 86 mono: ['var(--font-mono)', 'ui-monospace', 'monospace'],
87 - display: ['var(--font-display)', 'ui-serif', 'Georgia', 'serif'],
87 + display: ['var(--font-display)', 'ui-sans-serif', 'system-ui', 'sans-serif'],
88 88 },
89 89 },
90 90 },