ar9avg commited on
Commit
2d33bcd
Β·
1 Parent(s): d0e0cf7

feat: demo mode with reward chart, github diff, single-difficulty rounds, no loop

Browse files
Files changed (1) hide show
  1. frontend/src/components/DemoMode.tsx +459 -602
frontend/src/components/DemoMode.tsx CHANGED
@@ -1,56 +1,60 @@
1
  /**
2
  * DemoMode β€” scripted autoplay showcase for SQL Agent OpenEnv.
3
  *
4
- * Demonstrates: SQL generation β†’ RL repair loop β†’ GEPA prompt evolution β†’ improvement.
5
- * Fully client-side, no real API calls. Mirrors the /showcase pattern from the reference app.
 
6
  */
7
 
8
  import { useState, useRef, useCallback, useEffect } from 'react'
9
  import { motion, AnimatePresence } from 'framer-motion'
10
  import {
11
- Play, X, RotateCcw, Zap, CheckCircle2, XCircle,
12
- ChevronDown, ChevronUp, Sparkles, TrendingUp, Loader2,
 
 
 
 
13
  } from 'lucide-react'
14
 
15
- // ─── Scripted data ────────────────────────────────────────────────────────────
16
 
17
  const PROMPTS = [
18
- // Gen 0 β€” baseline
19
- `You are a SQL expert. Given a question and a SQLite database schema, write correct SQL.
20
 
21
  Rules:
22
  - Output ONLY the SQL query
23
  - Use SQLite syntax
24
  - No markdown, no code fences`,
25
 
26
- // Gen 1 β€” after first GEPA cycle
27
- `You are a SQL expert. Given a question and a SQLite database schema, write correct SQL.
28
 
29
  Rules:
30
  - Output ONLY the SQL query
31
  - Use SQLite syntax
32
  - No markdown, no code fences
33
  - Always qualify column names with table aliases in JOINs
34
- - Use t.column_name format to avoid ambiguous column errors
35
- - Check schema carefully before referencing any column name`,
36
 
37
- // Gen 2 β€” after second GEPA cycle
38
- `You are a SQL expert. Given a question and a SQLite database schema, write correct SQL.
39
 
40
  Rules:
41
  - Output ONLY the SQL query
42
  - Use SQLite syntax
43
  - No markdown, no code fences
44
  - Always qualify column names with table aliases in JOINs
45
- - Use t.column_name format to avoid ambiguous column errors
46
- - Check schema carefully before referencing any column name
47
- - For aggregations: always include non-aggregated columns in GROUP BY
48
- - For revenue calculations: use orders.total_price, not price
49
- - For rankings: use ORDER BY … DESC LIMIT N`,
50
  ]
51
 
52
  const SCORES = [0.42, 0.74, 0.91]
53
 
 
 
54
  interface Attempt {
55
  sql: string
56
  error?: string
@@ -63,222 +67,312 @@ interface Attempt {
63
  interface QueryDef {
64
  id: string
65
  question: string
66
- badge: string
67
- difficulty: 'Easy' | 'Medium' | 'Hard'
68
  attempts: Attempt[]
69
  }
70
 
71
  const QUERIES: Record<string, QueryDef> = {
72
- q1r1: {
73
- id: 'q1r1', question: 'Show all products', badge: 'Easy',
74
- difficulty: 'Easy',
 
75
  attempts: [
76
- {
77
- sql: 'SELECT * FROM product',
78
- error: 'no such table: product',
79
- errorClass: 'NO_SUCH_TABLE',
80
- rlAction: 'FIX_TABLE',
81
- reward: -0.15,
82
- },
83
- {
84
- sql: 'SELECT * FROM products',
85
- reward: 0.90,
86
- rows: [
87
- { id: 1, name: 'Wireless Headphones', category: 'Electronics', price: 79.99 },
88
- { id: 2, name: 'Running Shoes', category: 'Footwear', price: 59.99 },
89
- { id: 3, name: 'Coffee Maker', category: 'Kitchen', price: 49.99 },
90
- ],
91
- },
92
  ],
93
  },
94
- q2r1: {
95
- id: 'q2r1', question: 'Top 5 sellers by total revenue',
96
- badge: 'Medium', difficulty: 'Medium',
97
  attempts: [
98
- {
99
- sql: `SELECT seller_id, SUM(price) as revenue
100
- FROM orders
101
- GROUP BY seller_id
102
- ORDER BY revenue DESC
103
- LIMIT 5`,
104
- error: 'no such column: price',
105
- errorClass: 'NO_SUCH_COLUMN',
106
- rlAction: 'FIX_COLUMN',
107
- reward: -0.20,
108
- },
109
- {
110
- sql: `SELECT seller_id, SUM(total_price) as revenue
111
- FROM orders
112
- GROUP BY seller_id
113
- ORDER BY revenue DESC
114
- LIMIT 5`,
115
- error: 'ambiguous column name: seller_id',
116
- errorClass: 'AMBIGUOUS_COLUMN',
117
- rlAction: 'FIX_TABLE',
118
- reward: -0.25,
119
- },
120
- {
121
- sql: `SELECT s.name, SUM(o.total_price) as revenue
122
- FROM orders o
123
- JOIN sellers s ON o.user_id = s.id
124
- GROUP BY s.id, s.name
125
- ORDER BY revenue DESC
126
- LIMIT 5`,
127
- reward: 0.70,
128
- rows: [
129
- { name: 'TechStore Pro', revenue: 12840 },
130
- { name: 'StyleHub', revenue: 9320 },
131
- { name: 'GadgetWorld', revenue: 8750 },
132
- { name: 'HomeGoods Co', revenue: 7200 },
133
- { name: 'SportZone', revenue: 6100 },
134
- ],
135
- },
136
  ],
137
  },
138
- q3r1: {
139
- id: 'q3r1', question: 'Average order value by country',
140
- badge: 'Medium', difficulty: 'Medium',
 
 
141
  attempts: [
142
- {
143
- sql: `SELECT country, AVG(total_price) as avg_order
144
- FROM orders
145
- JOIN users ON user_id = users.id
146
- GROUP BY country`,
147
- error: 'ambiguous column name: country',
148
- errorClass: 'AMBIGUOUS_COLUMN',
149
- rlAction: 'FIX_COLUMN',
150
- reward: -0.15,
151
- },
152
- {
153
- sql: `SELECT u.country, ROUND(AVG(o.total_price), 2) as avg_order
154
- FROM orders o
155
- JOIN users u ON o.user_id = u.id
156
- GROUP BY u.country
157
- ORDER BY avg_order DESC`,
158
- reward: 0.85,
159
- rows: [
160
- { country: 'USA', avg_order: 142.50 },
161
- { country: 'UK', avg_order: 138.20 },
162
- { country: 'Germany', avg_order: 121.80 },
163
- { country: 'Canada', avg_order: 118.40 },
164
- ],
165
- },
166
  ],
167
  },
168
- // Round 2 β€” after GEPA, succeed faster
169
- q1r2: {
170
- id: 'q1r2', question: 'Show all products',
171
- badge: 'Easy', difficulty: 'Easy',
172
  attempts: [
173
- {
174
- sql: 'SELECT * FROM products',
175
- reward: 1.00,
176
- rows: [
177
- { id: 1, name: 'Wireless Headphones', category: 'Electronics', price: 79.99 },
178
- { id: 2, name: 'Running Shoes', category: 'Footwear', price: 59.99 },
179
- { id: 3, name: 'Coffee Maker', category: 'Kitchen', price: 49.99 },
180
- ],
181
- },
182
  ],
183
  },
184
- q2r2: {
185
- id: 'q2r2', question: 'Top 5 sellers by total revenue',
186
- badge: 'Medium', difficulty: 'Medium',
 
 
187
  attempts: [
188
- {
189
- sql: `SELECT s.name, SUM(o.total_price) as revenue
190
- FROM orders o
191
- JOIN sellers s ON o.user_id = s.id
192
- GROUP BY s.id, s.name
193
- ORDER BY revenue DESC
194
- LIMIT 5`,
195
- reward: 0.90,
196
- rows: [
197
- { name: 'TechStore Pro', revenue: 12840 },
198
- { name: 'StyleHub', revenue: 9320 },
199
- { name: 'GadgetWorld', revenue: 8750 },
200
- ],
201
- },
202
  ],
203
  },
204
- q3r2: {
205
- id: 'q3r2', question: 'Average order value by country',
206
- badge: 'Medium', difficulty: 'Medium',
207
  attempts: [
208
- {
209
- sql: `SELECT u.country, ROUND(AVG(o.total_price), 2) as avg_order
210
- FROM orders o
211
- JOIN users u ON o.user_id = u.id
212
- GROUP BY u.country
213
- ORDER BY avg_order DESC`,
214
- reward: 1.00,
215
- rows: [
216
- { country: 'USA', avg_order: 142.50 },
217
- { country: 'UK', avg_order: 138.20 },
218
- { country: 'Germany', avg_order: 121.80 },
219
- ],
220
- },
221
  ],
222
  },
223
  }
224
 
225
- const ROUND_1 = ['q1r1', 'q2r1', 'q3r1']
226
- const ROUND_2 = ['q1r2', 'q2r2', 'q3r2']
 
 
 
227
 
228
- // ─── Helpers ─────────────────────────────────────────────────────────────────
229
 
230
- let _idCounter = 0
231
- const uid = () => `bubble-${++_idCounter}`
 
 
232
 
233
- function sleep(ms: number): Promise<void> {
234
- return new Promise((res) => setTimeout(res, ms))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  }
236
 
237
- type AppState = 'idle' | 'running' | 'gepa' | 'done'
 
 
 
238
 
239
- interface BaseBubble { id: string }
240
- interface UserBubble extends BaseBubble { type: 'user'; text: string }
241
- interface ThinkingBubble extends BaseBubble { type: 'thinking'; label: string }
242
- interface SqlStreamBubble extends BaseBubble { type: 'sql_stream'; sql: string; attempt: number }
243
- interface SqlErrBubble extends BaseBubble { type: 'sql_err'; sql: string; error: string; errorClass: string; rlAction: string; reward: number; attempt: number }
244
- interface SqlOkBubble extends BaseBubble { type: 'sql_ok'; sql: string; rows: Record<string, string | number>[]; reward: number; attempt: number; badge: string; firstTry: boolean }
245
- interface GepaBubble extends BaseBubble { type: 'gepa'; fromGen: number; toGen: number; scoreFrom: number; scoreTo: number }
246
- interface GroupBubble extends BaseBubble { type: 'group'; question: string; badge: string; difficulty: string; success: boolean; attempts: number; children: BubbleData[] }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
- type BubbleData = UserBubble | ThinkingBubble | SqlStreamBubble | SqlErrBubble | SqlOkBubble | GepaBubble | GroupBubble
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
250
- // ─── SQL keyword highlighter ──────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
  function HighlightSQL({ sql }: { sql: string }) {
253
- const keywords = /\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|ON|GROUP BY|ORDER BY|HAVING|LIMIT|AS|AND|OR|SUM|AVG|COUNT|ROUND|DISTINCT|DESC|ASC|NULL|NOT|IN|IS)\b/gi
254
  const parts: React.ReactNode[] = []
255
- let last = 0
256
- let match: RegExpExecArray | null
257
- const re = new RegExp(keywords.source, 'gi')
258
- while ((match = re.exec(sql)) !== null) {
259
  if (match.index > last) parts.push(<span key={`t${last}`}>{sql.slice(last, match.index)}</span>)
260
  parts.push(<span key={`k${match.index}`} className="text-violet-300 font-semibold">{match[0]}</span>)
261
  last = match.index + match[0].length
262
  }
263
- if (last < sql.length) parts.push(<span key="tend">{sql.slice(last)}</span>)
264
  return <>{parts}</>
265
  }
266
 
267
- // ─── Bubble renderers ─────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  function Bubble({ b }: { b: BubbleData }) {
270
  const [open, setOpen] = useState(false)
271
 
272
  if (b.type === 'user') return (
273
  <div className="flex justify-end">
274
- <div className="max-w-[75%] bg-violet-600/20 border border-violet-500/25 rounded-2xl rounded-tr-sm px-4 py-2.5">
275
  <p className="text-sm text-white">{b.text}</p>
276
  </div>
277
  </div>
278
  )
279
 
280
  if (b.type === 'thinking') return (
281
- <div className="flex items-center gap-2 text-xs text-gray-500 px-1">
282
  <Loader2 size={11} className="animate-spin text-violet-400 shrink-0" />
283
  {b.label}
284
  </div>
@@ -288,10 +382,10 @@ function Bubble({ b }: { b: BubbleData }) {
288
  <div className="border border-white/[0.06] rounded-xl overflow-hidden">
289
  <div className="px-3 py-1.5 bg-white/[0.02] flex items-center gap-2">
290
  <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">SQL</span>
291
- <span className="text-[10px] text-gray-600">Attempt {b.attempt}</span>
292
  <Loader2 size={9} className="animate-spin text-violet-400 ml-auto" />
293
  </div>
294
- <pre className="px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap" style={{ color: 'rgba(221,214,254,0.8)', background: 'rgba(139,92,246,0.06)' }}>
295
  <HighlightSQL sql={b.sql} />
296
  <span className="inline-block w-0.5 h-[1em] bg-violet-400 animate-pulse align-bottom ml-0.5" />
297
  </pre>
@@ -303,26 +397,20 @@ function Bubble({ b }: { b: BubbleData }) {
303
  <div className="border border-white/[0.06] rounded-xl overflow-hidden">
304
  <div className="px-3 py-1.5 bg-white/[0.02] flex items-center gap-2">
305
  <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">SQL</span>
306
- <span className="text-[10px] text-gray-600">Attempt {b.attempt}</span>
307
  </div>
308
- <pre className="px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap" style={{ color: 'rgba(221,214,254,0.8)', background: 'rgba(139,92,246,0.06)' }}>
309
  <HighlightSQL sql={b.sql} />
310
  </pre>
311
  </div>
312
- <div className="flex items-start gap-2 bg-red-500/10 border border-red-500/20 rounded-xl px-3 py-2 text-xs text-red-300">
313
- <XCircle size={11} className="shrink-0 mt-0.5" />
314
- <div className="flex-1">
315
- <div className="flex items-center gap-2 flex-wrap">
316
- <span>{b.error}</span>
317
- <span className="text-[10px] px-1.5 py-0.5 bg-red-500/15 rounded-full text-red-400">{b.errorClass}</span>
318
- </div>
319
- </div>
320
- <div className="flex items-center gap-1.5 shrink-0">
321
- <span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full border border-orange-500/30 bg-orange-500/10 text-orange-400">
322
- <Zap size={8} />{b.rlAction}
323
- </span>
324
- <span className="text-[11px] font-bold text-red-400">{b.reward.toFixed(2)}</span>
325
- </div>
326
  </div>
327
  </div>
328
  )
@@ -333,41 +421,38 @@ function Bubble({ b }: { b: BubbleData }) {
333
  <div className="px-3 py-1.5 bg-white/[0.02] flex items-center gap-2">
334
  <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">SQL</span>
335
  {b.firstTry && (
336
- <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 border border-green-500/25 text-green-400 font-semibold">first try</span>
337
  )}
 
338
  </div>
339
- <pre className="px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap" style={{ color: 'rgba(221,214,254,0.8)', background: 'rgba(139,92,246,0.06)' }}>
340
  <HighlightSQL sql={b.sql} />
341
  </pre>
342
  </div>
343
- <div className="flex items-center gap-2 text-[10px] px-0.5">
344
  <CheckCircle2 size={11} className="text-green-400" />
345
  <span className="text-green-400 font-semibold">Success</span>
346
- <span className="text-gray-600">Β· {b.rows.length} rows</span>
347
- <span className="ml-auto text-[11px] font-bold text-green-400">+{b.reward.toFixed(2)}</span>
348
  </div>
349
- {/* Results mini-table */}
350
  <div className="rounded-xl border border-white/[0.06] overflow-hidden text-[10px]">
351
- <div className="grid overflow-x-auto">
352
- <table className="w-full">
353
- <thead>
354
- <tr className="bg-white/[0.03]">
355
- {Object.keys(b.rows[0] ?? {}).map((k) => (
356
- <th key={k} className="px-2 py-1.5 text-left font-semibold text-gray-500 whitespace-nowrap">{k}</th>
 
 
 
 
 
 
 
357
  ))}
358
  </tr>
359
- </thead>
360
- <tbody>
361
- {b.rows.map((row, i) => (
362
- <tr key={i} className={i % 2 === 0 ? 'bg-white/[0.01]' : ''}>
363
- {Object.values(row).map((v, j) => (
364
- <td key={j} className="px-2 py-1 text-gray-300 whitespace-nowrap">{String(v)}</td>
365
- ))}
366
- </tr>
367
- ))}
368
- </tbody>
369
- </table>
370
- </div>
371
  </div>
372
  </div>
373
  )
@@ -375,201 +460,104 @@ function Bubble({ b }: { b: BubbleData }) {
375
  if (b.type === 'gepa') return (
376
  <div className="border border-violet-500/25 rounded-2xl overflow-hidden bg-violet-500/5">
377
  <div className="px-4 py-3 flex items-center gap-2 border-b border-violet-500/15">
378
- <Sparkles size={13} className="text-violet-400" />
379
- <span className="text-xs font-semibold text-violet-300">GEPA Prompt Evolution</span>
380
- <span className="ml-auto text-[10px] text-violet-400/70">Gen {b.fromGen} β†’ Gen {b.toGen}</span>
381
  </div>
382
- <div className="px-4 py-3 flex items-center gap-4">
383
- <div className="flex flex-col items-center gap-0.5">
384
- <div className="text-[10px] text-gray-600">Before</div>
385
- <div className="text-lg font-bold text-orange-400">{(b.scoreFrom * 100).toFixed(0)}%</div>
386
  </div>
387
- <TrendingUp size={18} className="text-violet-400" />
388
- <div className="flex flex-col items-center gap-0.5">
389
- <div className="text-[10px] text-gray-600">After</div>
390
- <div className="text-lg font-bold text-green-400">{(b.scoreTo * 100).toFixed(0)}%</div>
391
  </div>
392
- <div className="ml-auto text-[10px] text-gray-500">
393
- System prompt updated with<br />targeted repair rules
 
394
  </div>
395
  </div>
396
- </div>
397
- )
398
-
399
- if (b.type === 'group') {
400
- const color = b.difficulty === 'Easy' ? 'text-green-400' : b.difficulty === 'Hard' ? 'text-red-400' : 'text-amber-400'
401
- return (
402
- <div className="border border-white/[0.06] rounded-2xl overflow-hidden">
403
- <button
404
- onClick={() => setOpen((v) => !v)}
405
- className="w-full flex items-center gap-2 px-3 py-2.5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors text-left"
406
- >
407
- {b.success
408
- ? <CheckCircle2 size={12} className="text-green-400 shrink-0" />
409
- : <XCircle size={12} className="text-red-400 shrink-0" />
410
- }
411
- <span className="text-xs text-gray-300 flex-1 truncate">{b.question}</span>
412
- <span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full border ${color} border-current/30 bg-current/10`}>{b.badge}</span>
413
- <span className="text-[10px] text-gray-600">{b.attempts} attempt{b.attempts !== 1 ? 's' : ''}</span>
414
- {open ? <ChevronUp size={11} className="text-gray-600 shrink-0" /> : <ChevronDown size={11} className="text-gray-600 shrink-0" />}
415
- </button>
416
- <AnimatePresence>
417
- {open && (
418
- <motion.div
419
- initial={{ height: 0, opacity: 0 }}
420
- animate={{ height: 'auto', opacity: 1 }}
421
- exit={{ height: 0, opacity: 0 }}
422
- transition={{ duration: 0.15 }}
423
- className="overflow-hidden"
424
- >
425
- <div className="p-3 flex flex-col gap-2.5 border-t border-white/[0.04]">
426
- {b.children.map((child) => <Bubble key={child.id} b={child} />)}
427
- </div>
428
- </motion.div>
429
- )}
430
- </AnimatePresence>
431
- </div>
432
- )
433
- }
434
-
435
- return null
436
- }
437
-
438
- // ─── Right panel β€” prompt + score ─────────────────────────────────────────────
439
-
440
- function PromptPanel({ gen, score }: { gen: number; score: number }) {
441
- const [open, setOpen] = useState(false)
442
- const prompt = PROMPTS[gen] ?? PROMPTS[0]
443
- const lines = prompt.split('\n')
444
-
445
- return (
446
- <div className="flex flex-col gap-3 px-4 py-4">
447
- <div className="flex items-center justify-between">
448
- <div className="flex items-center gap-2">
449
- <Sparkles size={12} className="text-violet-400" />
450
- <span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">GEPA Prompt</span>
451
  </div>
452
- <span className="text-[10px] px-2 py-0.5 rounded-full bg-violet-500/15 border border-violet-500/25 text-violet-400 font-semibold">
453
- Gen {gen}
454
- </span>
455
- </div>
456
-
457
- {/* Score */}
458
- <div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-3 flex items-center gap-3">
459
- <div>
460
- <div className="text-[10px] text-gray-600 mb-0.5">Benchmark score</div>
461
- <div className="text-2xl font-bold text-green-400 tabular-nums">{(score * 100).toFixed(0)}%</div>
462
- </div>
463
- <div className="flex-1 h-1.5 bg-white/[0.05] rounded-full overflow-hidden">
464
- <motion.div
465
- className="h-full bg-gradient-to-r from-violet-500 to-green-400 rounded-full"
466
- animate={{ width: `${score * 100}%` }}
467
- transition={{ duration: 0.8, ease: 'easeOut' }}
468
- />
469
  </div>
470
  </div>
 
 
471
 
472
- {/* Prompt preview */}
473
- <div className="border border-white/[0.06] rounded-xl overflow-hidden">
474
- <button
475
- onClick={() => setOpen((v) => !v)}
476
- className="w-full flex items-center justify-between px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors"
477
- >
478
- <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">System Prompt</span>
479
- {open ? <ChevronUp size={11} className="text-gray-600" /> : <ChevronDown size={11} className="text-gray-600" />}
480
- </button>
481
- <AnimatePresence>
482
- {open && (
483
- <motion.div
484
- initial={{ height: 0 }}
485
- animate={{ height: 'auto' }}
486
- exit={{ height: 0 }}
487
- transition={{ duration: 0.15 }}
488
- className="overflow-hidden"
489
- >
490
- <div className="px-3 py-2.5 text-[10px] leading-relaxed font-mono text-gray-400 max-h-48 overflow-y-auto">
491
- {lines.map((line, i) => {
492
- const isNew = gen > 0 && i >= PROMPTS[gen - 1].split('\n').length
493
- return (
494
- <div
495
- key={i}
496
- className={isNew ? 'text-green-400 bg-green-500/8 -mx-3 px-3' : ''}
497
- >
498
- {line || <br />}
499
- </div>
500
- )
501
- })}
502
- </div>
503
- </motion.div>
504
- )}
505
- </AnimatePresence>
506
- </div>
507
-
508
- {/* Score history */}
509
- <div className="flex flex-col gap-1">
510
- <div className="text-[10px] text-gray-600 font-semibold uppercase tracking-wider mb-0.5">Evolution</div>
511
- {SCORES.slice(0, gen + 1).map((s, i) => (
512
- <div key={i} className="flex items-center gap-2">
513
- <span className="text-[10px] text-gray-600 w-10 shrink-0">Gen {i}</span>
514
- <div className="flex-1 h-1 bg-white/[0.05] rounded-full overflow-hidden">
515
- <div
516
- className="h-full rounded-full"
517
- style={{
518
- width: `${s * 100}%`,
519
- background: i === gen ? 'linear-gradient(90deg,#8b5cf6,#22c55e)' : '#374151',
520
- }}
521
- />
522
  </div>
523
- <span className={`text-[10px] font-bold tabular-nums ${i === gen ? 'text-green-400' : 'text-gray-600'}`}>
524
- {(s * 100).toFixed(0)}%
525
- </span>
526
- </div>
527
- ))}
528
- </div>
529
  </div>
530
  )
531
- }
532
 
533
- // ─── Main DemoMode Component ──────────────────────────────────────────────────
534
-
535
- interface DemoModeProps {
536
- onClose: () => void
537
  }
538
 
539
- export function DemoMode({ onClose }: DemoModeProps) {
 
 
540
  const [bubbles, setBubbles] = useState<BubbleData[]>([])
541
- const [appState, setAppState] = useState<AppState>('idle')
542
  const [gen, setGen] = useState(0)
543
  const [score, setScore] = useState(SCORES[0])
 
 
 
544
  const cancel = useRef(false)
545
  const bottomRef = useRef<HTMLDivElement>(null)
546
 
547
- const push = useCallback((b: BubbleData) => {
548
- setBubbles((prev) => [...prev, b])
549
  }, [])
550
 
551
- const replaceLast = useCallback((b: BubbleData) => {
552
- setBubbles((prev) => [...prev.slice(0, -1), b])
553
- }, [])
554
 
555
- const scrollDown = useCallback(() => {
556
- setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
 
557
  }, [])
558
 
559
- // Type text character by character
560
  const typeUser = useCallback(async (text: string) => {
561
  const id = uid()
562
  push({ id, type: 'user', text: '' })
563
  for (let i = 1; i <= text.length; i++) {
564
  if (cancel.current) return
565
- setBubbles((prev) => prev.map((b) => (b.id === id ? { ...b, text: text.slice(0, i) } : b)))
566
- await sleep(35 + Math.random() * 25)
567
  }
568
- scrollDown()
569
- await sleep(350)
570
- }, [push, scrollDown])
571
 
572
- // Stream SQL line by line
573
  const streamSQL = useCallback(async (sql: string, attempt: number) => {
574
  const id = uid()
575
  push({ id, type: 'sql_stream', sql: '', attempt })
@@ -577,281 +565,168 @@ export function DemoMode({ onClose }: DemoModeProps) {
577
  for (const line of sql.split('\n')) {
578
  if (cancel.current) return
579
  built += (built ? '\n' : '') + line
580
- setBubbles((prev) => prev.map((b) => (b.id === id ? { ...b, sql: built } : b)))
581
- await sleep(90 + Math.random() * 80)
582
  }
583
- scrollDown()
584
- await sleep(250)
585
- // Remove stream bubble (will be replaced by err or ok bubble)
586
- setBubbles((prev) => prev.filter((b) => b.id !== id))
587
- }, [push, scrollDown])
588
 
589
- // Play one full query (possibly multiple attempts)
590
  const playQuery = useCallback(async (def: QueryDef): Promise<BubbleData[]> => {
591
  const children: BubbleData[] = []
592
-
593
  await typeUser(def.question)
594
 
595
  for (let i = 0; i < def.attempts.length; i++) {
596
  if (cancel.current) return children
597
  const att = def.attempts[i]
598
- const attemptNum = i + 1
599
 
600
- // Show thinking
601
- const thinkId = uid()
602
- push({ id: thinkId, type: 'thinking', label: 'Generating SQL…' })
603
- scrollDown()
604
- await sleep(800)
605
  if (cancel.current) return children
606
- setBubbles((prev) => prev.filter((b) => b.id !== thinkId))
607
 
608
- await streamSQL(att.sql, attemptNum)
609
  if (cancel.current) return children
610
 
 
 
611
  if (att.error) {
612
- const errBubble: SqlErrBubble = {
613
- id: uid(), type: 'sql_err',
614
- sql: att.sql, error: att.error,
615
- errorClass: att.errorClass ?? 'OTHER',
616
- rlAction: att.rlAction ?? 'REWRITE_FULL',
617
- reward: att.reward,
618
- attempt: attemptNum,
619
- }
620
- push(errBubble)
621
- children.push(errBubble)
622
- scrollDown()
623
- await sleep(900)
624
  } else {
625
- const okBubble: SqlOkBubble = {
626
- id: uid(), type: 'sql_ok',
627
- sql: att.sql,
628
- rows: att.rows ?? [],
629
- reward: att.reward,
630
- attempt: attemptNum,
631
- badge: def.badge,
632
- firstTry: attemptNum === 1,
633
- }
634
- push(okBubble)
635
- children.push(okBubble)
636
- scrollDown()
637
- await sleep(1200)
638
  }
639
  }
640
-
641
  return children
642
- }, [push, scrollDown, typeUser, streamSQL])
643
 
644
- // Collapse query into a group bubble
645
  const collapseQuery = useCallback((def: QueryDef, children: BubbleData[]) => {
646
- const lastAttempt = def.attempts[def.attempts.length - 1]
647
- const success = !lastAttempt.error
648
-
649
- // Remove user bubble + children, replace with group
650
  setBubbles((prev) => {
651
- // Find user bubble for this question
652
- const userIdx = [...prev].reverse().findIndex(
653
- (b) => b.type === 'user' && (b as UserBubble).text === def.question
654
- )
655
  if (userIdx < 0) return prev
656
  const fromIdx = prev.length - 1 - userIdx
657
- const userBubble = prev[fromIdx] as UserBubble
658
-
659
- const group: GroupBubble = {
660
- id: uid(),
661
- type: 'group',
662
- question: userBubble.text,
663
- badge: def.badge,
664
- difficulty: def.difficulty,
665
- success,
666
- attempts: def.attempts.length,
667
- children,
668
- }
669
  return [...prev.slice(0, fromIdx), group]
670
  })
671
  }, [])
672
 
673
- // Animate GEPA cycle
674
  const playGepa = useCallback(async (fromGen: number, toGen: number) => {
675
- setAppState('gepa')
676
-
677
- const thinkId = uid()
678
- const steps = [
679
- 'Analyzing failure patterns…',
680
- 'Identifying missing rules…',
681
- 'Rewriting system prompt…',
682
- 'Benchmarking new prompt…',
683
- ]
684
- for (const step of steps) {
685
  if (cancel.current) return
686
- replaceLast({ id: thinkId, type: 'thinking', label: step })
687
- push({ id: thinkId, type: 'thinking', label: step })
688
- setBubbles((prev) => {
689
- const last = prev[prev.length - 1]
690
- if (last?.type === 'thinking') return [...prev.slice(0, -1), { id: thinkId, type: 'thinking', label: step }]
691
- return [...prev, { id: thinkId, type: 'thinking', label: step }]
692
  })
693
- scrollDown()
694
- await sleep(1100)
695
  }
696
- // Remove thinking
697
- setBubbles((prev) => prev.filter((b) => b.id !== thinkId))
698
 
699
  // Animate score
700
- const from = SCORES[fromGen]
701
- const to = SCORES[toGen]
702
- for (let i = 0; i <= 40; i++) {
703
  if (cancel.current) return
704
- setScore(from + (to - from) * (i / 40))
705
- await sleep(20)
706
  }
707
  setGen(toGen)
 
708
 
709
- // Push GEPA bubble
710
  push({ id: uid(), type: 'gepa', fromGen, toGen, scoreFrom: from, scoreTo: to })
711
- scrollDown()
712
- await sleep(1000)
713
- setAppState('running')
714
- }, [push, replaceLast, scrollDown])
715
 
716
  const autoPlay = useCallback(async () => {
717
  cancel.current = false
718
- setBubbles([])
719
- setGen(0)
720
- setScore(SCORES[0])
721
- setAppState('running')
722
- await sleep(400)
723
 
724
- // Round 1
725
  for (const id of ROUND_1) {
726
  if (cancel.current) break
727
- const def = QUERIES[id]
728
- const children = await playQuery(def)
729
  if (cancel.current) break
730
- await sleep(400)
731
- collapseQuery(def, children)
732
- await sleep(700)
733
  }
 
734
 
735
- if (!cancel.current) {
736
- await playGepa(0, 1)
737
- await sleep(500)
738
- }
739
-
740
- // Round 2
741
  for (const id of ROUND_2) {
742
  if (cancel.current) break
743
- const def = QUERIES[id]
744
- const children = await playQuery(def)
745
  if (cancel.current) break
746
- await sleep(400)
747
- collapseQuery(def, children)
748
- await sleep(700)
749
  }
 
750
 
751
- if (!cancel.current) {
752
- await playGepa(1, 2)
753
- setAppState('done')
 
 
754
  }
755
- }, [playQuery, collapseQuery, playGepa])
756
 
757
- const handleStart = () => void autoPlay()
758
-
759
- const handleReplay = () => {
760
- cancel.current = true
761
- setTimeout(() => {
762
- cancel.current = false
763
- setBubbles([])
764
- setGen(0)
765
- setScore(SCORES[0])
766
- setAppState('idle')
767
- }, 200)
768
- }
769
 
770
- useEffect(() => {
771
- return () => { cancel.current = true }
772
- }, [])
773
 
774
  return (
775
  <motion.div
776
- initial={{ opacity: 0 }}
777
- animate={{ opacity: 1 }}
778
- exit={{ opacity: 0 }}
779
  className="fixed inset-0 z-[100] flex flex-col"
780
  style={{ background: 'var(--bg-primary)' }}
781
  >
782
- {/* Header bar */}
783
- <div
784
- className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-white/[0.06]"
785
- style={{ background: 'var(--bg-secondary)' }}
786
- >
787
  <div className="flex items-center gap-3">
788
  <div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-violet-500/15 border border-violet-500/25">
789
- <Play size={10} className="text-violet-400" fill="currentColor" />
790
- <span className="text-[11px] font-semibold text-violet-300">Demo Mode</span>
791
  </div>
792
- <span className="text-xs text-gray-500 hidden sm:block">
793
- Watching SQL Agent learn through RL repair loop + GEPA prompt evolution
794
  </span>
795
  </div>
796
- <div className="flex items-center gap-2">
797
- {appState !== 'idle' && (
798
- <button
799
- onClick={handleReplay}
800
- className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-white/[0.06] text-[11px] text-gray-400 hover:text-white hover:bg-white/5 transition-all"
801
- >
802
- <RotateCcw size={11} />
803
- Replay
804
- </button>
805
- )}
806
- <button
807
- onClick={onClose}
808
- className="p-1.5 rounded-lg hover:bg-white/5 transition-colors text-gray-500 hover:text-white"
809
- >
810
- <X size={16} />
811
- </button>
812
- </div>
813
  </div>
814
 
815
  {/* Body */}
816
  <div className="flex flex-1 overflow-hidden">
817
- {/* Chat column */}
818
  <div className="flex-1 flex flex-col overflow-hidden">
819
  <div className="flex-1 overflow-y-auto px-4 py-4">
820
  {appState === 'idle' ? (
821
- /* Start screen */
822
- <div className="flex flex-col items-center justify-center h-full gap-6 text-center px-6">
823
- <div
824
- className="w-16 h-16 rounded-2xl flex items-center justify-center"
825
- style={{ background: 'linear-gradient(135deg,#3b0764,#1e3a5f)', boxShadow: '0 12px 40px rgba(139,92,246,0.3)' }}
826
- >
827
- <Play size={26} className="text-white ml-0.5" fill="currentColor" />
828
  </div>
829
  <div>
830
- <h2 className="text-lg font-bold text-white mb-2">SQL Agent in Action</h2>
831
- <p className="text-sm text-gray-500 max-w-sm">
832
- Watch the agent fail on ambiguous queries, use LinUCB to pick repair strategies,
833
- and improve via GEPA prompt evolution β€” from 42% β†’ 91% accuracy.
834
  </p>
835
  </div>
836
- <div className="flex flex-col gap-2 text-xs text-gray-600 max-w-xs">
837
- {[
838
- 'Round 1 β€” 3 queries with RL repair loop',
839
- 'GEPA cycle β€” prompt evolves Gen 0 β†’ Gen 1',
840
- 'Round 2 β€” same queries succeed faster',
841
- 'GEPA cycle β€” Gen 1 β†’ Gen 2 (91% accuracy)',
842
- ].map((s, i) => (
843
- <div key={i} className="flex items-center gap-2">
844
- <div className="w-4 h-4 rounded-full bg-violet-500/20 border border-violet-500/30 flex items-center justify-center text-[9px] text-violet-400 font-bold shrink-0">{i + 1}</div>
845
  {s}
846
  </div>
847
  ))}
848
  </div>
849
  <button
850
- onClick={handleStart}
851
- className="flex items-center gap-2 px-6 py-3 rounded-2xl font-semibold text-sm text-white transition-all active:scale-95"
852
  style={{ background: 'linear-gradient(135deg,#7c3aed,#2563eb)', boxShadow: '0 8px 24px rgba(124,58,237,0.4)' }}
853
  >
854
- <Play size={14} fill="currentColor" />
855
  Start Demo
856
  </button>
857
  </div>
@@ -859,34 +734,19 @@ export function DemoMode({ onClose }: DemoModeProps) {
859
  <div className="flex flex-col gap-4 max-w-2xl mx-auto">
860
  <AnimatePresence initial={false}>
861
  {bubbles.map((b) => (
862
- <motion.div
863
- key={b.id}
864
- initial={{ opacity: 0, y: 8 }}
865
- animate={{ opacity: 1, y: 0 }}
866
- transition={{ duration: 0.2 }}
867
- >
868
  <Bubble b={b} />
869
  </motion.div>
870
  ))}
871
  </AnimatePresence>
 
872
  {appState === 'done' && (
873
- <motion.div
874
- initial={{ opacity: 0, y: 12 }}
875
- animate={{ opacity: 1, y: 0 }}
876
- className="border border-green-500/25 rounded-2xl p-4 bg-green-500/5 text-center"
877
- >
878
- <div className="text-2xl font-bold text-green-400 mb-1">91%</div>
879
- <div className="text-sm text-white font-semibold mb-1">Demo complete</div>
880
- <div className="text-xs text-gray-500">
881
- Agent improved from 42% β†’ 91% through RL repair + GEPA evolution
882
  </div>
883
- <button
884
- onClick={handleReplay}
885
- className="mt-3 flex items-center gap-1.5 px-4 py-2 rounded-xl border border-white/[0.06] text-xs text-gray-400 hover:text-white hover:bg-white/5 transition-all mx-auto"
886
- >
887
- <RotateCcw size={11} />
888
- Watch again
889
- </button>
890
  </motion.div>
891
  )}
892
  <div ref={bottomRef} />
@@ -895,12 +755,9 @@ export function DemoMode({ onClose }: DemoModeProps) {
895
  </div>
896
  </div>
897
 
898
- {/* Right panel β€” prompt evolution */}
899
- <aside
900
- className="hidden lg:flex flex-col w-72 border-l border-white/[0.06] overflow-y-auto shrink-0"
901
- style={{ background: 'var(--bg-secondary)' }}
902
- >
903
- <PromptPanel gen={gen} score={score} />
904
  </aside>
905
  </div>
906
  </motion.div>
 
1
  /**
2
  * DemoMode β€” scripted autoplay showcase for SQL Agent OpenEnv.
3
  *
4
+ * Right panel: live reward chart + GitHub-style prompt diff.
5
+ * Chat: single-difficulty rounds separated by GEPA evolution.
6
+ * No looping β€” scrollable at the end.
7
  */
8
 
9
  import { useState, useRef, useCallback, useEffect } from 'react'
10
  import { motion, AnimatePresence } from 'framer-motion'
11
  import {
12
+ LineChart, Line, XAxis, YAxis, CartesianGrid,
13
+ Tooltip, ResponsiveContainer, ReferenceLine,
14
+ } from 'recharts'
15
+ import {
16
+ Play, X, Zap, CheckCircle2, XCircle,
17
+ ChevronDown, ChevronUp, Sparkles, Loader2, GitCommitHorizontal,
18
  } from 'lucide-react'
19
 
20
+ // ─── Prompts (3 generations) ─────────────────────────────────────────────────
21
 
22
  const PROMPTS = [
23
+ `You are a SQL expert. Given a question and a SQLite schema, write correct SQL.
 
24
 
25
  Rules:
26
  - Output ONLY the SQL query
27
  - Use SQLite syntax
28
  - No markdown, no code fences`,
29
 
30
+ `You are a SQL expert. Given a question and a SQLite schema, write correct SQL.
 
31
 
32
  Rules:
33
  - Output ONLY the SQL query
34
  - Use SQLite syntax
35
  - No markdown, no code fences
36
  - Always qualify column names with table aliases in JOINs
37
+ - Use t.column_name to avoid ambiguous column errors
38
+ - Verify every column name against the schema before use`,
39
 
40
+ `You are a SQL expert. Given a question and a SQLite schema, write correct SQL.
 
41
 
42
  Rules:
43
  - Output ONLY the SQL query
44
  - Use SQLite syntax
45
  - No markdown, no code fences
46
  - Always qualify column names with table aliases in JOINs
47
+ - Use t.column_name to avoid ambiguous column errors
48
+ - Verify every column name against the schema before use
49
+ - For aggregations: include all non-aggregated columns in GROUP BY
50
+ - For revenue: use orders.total_price (not price or amount)
51
+ - For top-N: use ORDER BY … DESC LIMIT N`,
52
  ]
53
 
54
  const SCORES = [0.42, 0.74, 0.91]
55
 
56
+ // ─── Scripted query data ─────────────────────────────────────────────────────
57
+
58
  interface Attempt {
59
  sql: string
60
  error?: string
 
67
  interface QueryDef {
68
  id: string
69
  question: string
 
 
70
  attempts: Attempt[]
71
  }
72
 
73
  const QUERIES: Record<string, QueryDef> = {
74
+ // Round 1 β€” simple queries, some fail
75
+ r1q1: {
76
+ id: 'r1q1',
77
+ question: 'Show all products',
78
  attempts: [
79
+ { sql: 'SELECT * FROM product', error: 'no such table: product', errorClass: 'NO_SUCH_TABLE', rlAction: 'FIX_TABLE', reward: -0.15 },
80
+ { sql: 'SELECT * FROM products LIMIT 10', reward: 0.90,
81
+ rows: [{ id: 1, name: 'Wireless Headphones', category: 'Electronics', price: 79.99 }, { id: 2, name: 'Running Shoes', category: 'Footwear', price: 59.99 }, { id: 3, name: 'Coffee Maker', category: 'Kitchen', price: 49.99 }] },
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  ],
83
  },
84
+ r1q2: {
85
+ id: 'r1q2',
86
+ question: 'List all users from the USA',
87
  attempts: [
88
+ { sql: "SELECT * FROM users WHERE country = 'USA'", reward: 1.00,
89
+ rows: [{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', country: 'USA' }, { id: 2, name: 'Bob Smith', email: 'bob@example.com', country: 'USA' }] },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  ],
91
  },
92
+
93
+ // Round 2 β€” join queries, ambiguous columns fixed by GEPA
94
+ r2q1: {
95
+ id: 'r2q1',
96
+ question: 'Top 5 sellers by total revenue',
97
  attempts: [
98
+ { sql: `SELECT seller_id, SUM(total_price) as revenue\nFROM orders\nGROUP BY seller_id\nORDER BY revenue DESC\nLIMIT 5`, error: 'ambiguous column name: seller_id', errorClass: 'AMBIGUOUS_COLUMN', rlAction: 'FIX_COLUMN', reward: -0.20 },
99
+ { sql: `SELECT s.name, SUM(o.total_price) as revenue\nFROM orders o\nJOIN sellers s ON o.user_id = s.id\nGROUP BY s.id, s.name\nORDER BY revenue DESC\nLIMIT 5`, reward: 0.80,
100
+ rows: [{ seller: 'TechStore Pro', revenue: 12840 }, { seller: 'StyleHub', revenue: 9320 }, { seller: 'GadgetWorld', revenue: 8750 }] },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  ],
102
  },
103
+ r2q2: {
104
+ id: 'r2q2',
105
+ question: 'Average order value by country',
 
106
  attempts: [
107
+ { sql: `SELECT country, AVG(total_price) as avg_order\nFROM orders\nJOIN users ON user_id = users.id\nGROUP BY country`, error: 'ambiguous column name: country', errorClass: 'AMBIGUOUS_COLUMN', rlAction: 'FIX_COLUMN', reward: -0.15 },
108
+ { sql: `SELECT u.country, ROUND(AVG(o.total_price), 2) as avg_order\nFROM orders o\nJOIN users u ON o.user_id = u.id\nGROUP BY u.country\nORDER BY avg_order DESC`, reward: 0.85,
109
+ rows: [{ country: 'USA', avg_order: 142.50 }, { country: 'UK', avg_order: 138.20 }, { country: 'Germany', avg_order: 121.80 }] },
 
 
 
 
 
 
110
  ],
111
  },
112
+
113
+ // Round 3 β€” after GEPA 2, same joins succeed first try
114
+ r3q1: {
115
+ id: 'r3q1',
116
+ question: 'Top 5 sellers by total revenue',
117
  attempts: [
118
+ { sql: `SELECT s.name, SUM(o.total_price) as revenue\nFROM orders o\nJOIN sellers s ON o.user_id = s.id\nGROUP BY s.id, s.name\nORDER BY revenue DESC\nLIMIT 5`, reward: 1.00,
119
+ rows: [{ seller: 'TechStore Pro', revenue: 12840 }, { seller: 'StyleHub', revenue: 9320 }, { seller: 'GadgetWorld', revenue: 8750 }] },
 
 
 
 
 
 
 
 
 
 
 
 
120
  ],
121
  },
122
+ r3q2: {
123
+ id: 'r3q2',
124
+ question: 'Monthly revenue for the last 6 months',
125
  attempts: [
126
+ { sql: `SELECT strftime('%Y-%m', created_at) as month,\n ROUND(SUM(total_price), 2) as revenue\nFROM orders\nGROUP BY month\nORDER BY month DESC\nLIMIT 6`, reward: 1.00,
127
+ rows: [{ month: '2024-11', revenue: 24180 }, { month: '2024-10', revenue: 21340 }, { month: '2024-09', revenue: 19800 }] },
 
 
 
 
 
 
 
 
 
 
 
128
  ],
129
  },
130
  }
131
 
132
+ const ROUND_1 = ['r1q1', 'r1q2']
133
+ const ROUND_2 = ['r2q1', 'r2q2']
134
+ const ROUND_3 = ['r3q1', 'r3q2']
135
+
136
+ // ─── GitHub-style diff ───────────────────────────────────────────────────────
137
 
138
+ interface DiffLine { type: 'add' | 'remove' | 'same'; text: string }
139
 
140
+ function diffPrompts(fromIdx: number, toIdx: number): DiffLine[] {
141
+ const oldLines = PROMPTS[fromIdx].split('\n')
142
+ const newLines = PROMPTS[toIdx].split('\n')
143
+ const result: DiffLine[] = []
144
 
145
+ // Simple patience diff: lines in both = same, only in old = remove, only in new = add
146
+ const oldSet = new Set(oldLines)
147
+ const newSet = new Set(newLines)
148
+
149
+ // Walk new lines, carrying old-only lines before matching
150
+ let oi = 0
151
+ for (let ni = 0; ni < newLines.length; ni++) {
152
+ const line = newLines[ni]
153
+ if (oldSet.has(line)) {
154
+ // Flush any old-only lines before this match
155
+ while (oi < oldLines.length && oldLines[oi] !== line) {
156
+ if (!newSet.has(oldLines[oi])) result.push({ type: 'remove', text: oldLines[oi] })
157
+ oi++
158
+ }
159
+ result.push({ type: 'same', text: line })
160
+ oi++
161
+ } else {
162
+ result.push({ type: 'add', text: line })
163
+ }
164
+ }
165
+ // Remaining old-only
166
+ while (oi < oldLines.length) {
167
+ if (!newSet.has(oldLines[oi])) result.push({ type: 'remove', text: oldLines[oi] })
168
+ oi++
169
+ }
170
+ return result
171
  }
172
 
173
+ function GithubDiff({ fromIdx, toIdx }: { fromIdx: number; toIdx: number }) {
174
+ const lines = diffPrompts(fromIdx, toIdx)
175
+ const added = lines.filter((l) => l.type === 'add').length
176
+ const removed = lines.filter((l) => l.type === 'remove').length
177
 
178
+ return (
179
+ <div className="rounded-xl border border-white/[0.06] overflow-hidden text-[11px] font-mono">
180
+ {/* Header */}
181
+ <div className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] border-b border-white/[0.05]">
182
+ <GitCommitHorizontal size={11} className="text-gray-500" />
183
+ <span className="text-gray-400 font-semibold">system_prompt.txt</span>
184
+ <span className="ml-auto flex items-center gap-2 text-[10px]">
185
+ <span className="text-green-400 font-bold">+{added}</span>
186
+ <span className="text-red-400 font-bold">βˆ’{removed}</span>
187
+ </span>
188
+ </div>
189
+ {/* Diff lines */}
190
+ <div className="max-h-52 overflow-y-auto">
191
+ {lines.map((line, i) => {
192
+ const bg = line.type === 'add' ? 'bg-green-500/10' : line.type === 'remove' ? 'bg-red-500/10' : ''
193
+ const prefix = line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' '
194
+ const color = line.type === 'add' ? 'text-green-300' : line.type === 'remove' ? 'text-red-300' : 'text-gray-500'
195
+ return (
196
+ <div key={i} className={`flex items-start gap-2 px-3 py-0.5 leading-relaxed ${bg}`}>
197
+ <span className={`shrink-0 w-3 ${color} font-bold select-none`}>{prefix}</span>
198
+ <span className={color}>{line.text || <span className="opacity-0">β€”</span>}</span>
199
+ </div>
200
+ )
201
+ })}
202
+ </div>
203
+ </div>
204
+ )
205
+ }
206
 
207
+ // ─── Reward chart ────────────────────────────────────────────────────────────
208
+
209
+ interface RewardPoint { step: number; reward: number }
210
+
211
+ function RewardChart({ points }: { points: RewardPoint[] }) {
212
+ if (points.length === 0) {
213
+ return (
214
+ <div className="flex flex-col items-center justify-center h-28 gap-1.5 text-gray-700">
215
+ <span className="text-[11px]">Rewards appear as agent runs</span>
216
+ </div>
217
+ )
218
+ }
219
+ return (
220
+ <ResponsiveContainer width="100%" height={110}>
221
+ <LineChart data={points} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
222
+ <CartesianGrid strokeDasharray="3 3" stroke="#ffffff08" />
223
+ <XAxis dataKey="step" tick={{ fontSize: 9, fill: '#6b7280' }} />
224
+ <YAxis domain={[-0.5, 1.1]} tick={{ fontSize: 9, fill: '#6b7280' }} />
225
+ <Tooltip
226
+ contentStyle={{ background: '#1a1a2e', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, fontSize: 11 }}
227
+ labelStyle={{ color: '#9ca3af' }}
228
+ itemStyle={{ color: '#f97316' }}
229
+ formatter={(v: number) => [v.toFixed(2), 'reward']}
230
+ labelFormatter={(l) => `Step ${l}`}
231
+ />
232
+ <ReferenceLine y={0} stroke="#ffffff18" strokeDasharray="3 3" />
233
+ <Line
234
+ type="monotone" dataKey="reward" name="Reward"
235
+ stroke="#f97316" strokeWidth={2}
236
+ dot={(props) => {
237
+ const { cx, cy, payload } = props
238
+ const color = (payload as RewardPoint).reward >= 0 ? '#22c55e' : '#ef4444'
239
+ return <circle key={`dot-${(payload as RewardPoint).step}`} cx={cx} cy={cy} r={3} fill={color} stroke="none" />
240
+ }}
241
+ activeDot={{ r: 4, fill: '#f97316' }}
242
+ isAnimationActive={false}
243
+ />
244
+ </LineChart>
245
+ </ResponsiveContainer>
246
+ )
247
+ }
248
+
249
+ // ─── Right panel ─────────────────────────────────────────────────────────────
250
+
251
+ interface RightPanelProps {
252
+ gen: number
253
+ score: number
254
+ rewardPoints: RewardPoint[]
255
+ latestDiff: { from: number; to: number } | null
256
+ }
257
+
258
+ function RightPanel({ gen, score, rewardPoints, latestDiff }: RightPanelProps) {
259
+ return (
260
+ <div className="flex flex-col gap-4 px-4 py-4 overflow-y-auto h-full">
261
+ {/* Gen badge + score */}
262
+ <div className="flex items-center justify-between">
263
+ <div className="flex items-center gap-1.5">
264
+ <Sparkles size={12} className="text-violet-400" />
265
+ <span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">GEPA</span>
266
+ </div>
267
+ <span className="text-[10px] px-2 py-0.5 rounded-full bg-violet-500/15 border border-violet-500/25 text-violet-400 font-semibold">
268
+ Gen {gen}
269
+ </span>
270
+ </div>
271
+
272
+ {/* Score bar */}
273
+ <div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-3">
274
+ <div className="flex items-end gap-2 mb-2">
275
+ <span className="text-2xl font-bold text-green-400 tabular-nums leading-none">
276
+ {(score * 100).toFixed(0)}%
277
+ </span>
278
+ <span className="text-[10px] text-gray-600 mb-0.5">benchmark score</span>
279
+ </div>
280
+ <div className="h-1.5 bg-white/[0.05] rounded-full overflow-hidden">
281
+ <motion.div
282
+ className="h-full rounded-full"
283
+ style={{ background: 'linear-gradient(90deg,#8b5cf6,#22c55e)' }}
284
+ animate={{ width: `${score * 100}%` }}
285
+ transition={{ duration: 0.9, ease: 'easeOut' }}
286
+ />
287
+ </div>
288
+ {/* Generation history */}
289
+ <div className="mt-2.5 flex flex-col gap-1">
290
+ {SCORES.slice(0, gen + 1).map((s, i) => (
291
+ <div key={i} className="flex items-center gap-2 text-[10px]">
292
+ <span className="text-gray-600 w-10 shrink-0">Gen {i}</span>
293
+ <div className="flex-1 h-1 bg-white/[0.05] rounded-full overflow-hidden">
294
+ <div className="h-full rounded-full transition-all duration-700"
295
+ style={{ width: `${s * 100}%`, background: i === gen ? 'linear-gradient(90deg,#8b5cf6,#22c55e)' : '#374151' }} />
296
+ </div>
297
+ <span className={`font-bold tabular-nums ${i === gen ? 'text-green-400' : 'text-gray-600'}`}>
298
+ {(s * 100).toFixed(0)}%
299
+ </span>
300
+ </div>
301
+ ))}
302
+ </div>
303
+ </div>
304
 
305
+ {/* Reward chart */}
306
+ <div>
307
+ <div className="text-[10px] text-gray-600 font-semibold uppercase tracking-wider mb-2">
308
+ RL Reward per Step
309
+ </div>
310
+ <RewardChart points={rewardPoints} />
311
+ </div>
312
+
313
+ {/* GitHub diff β€” appears after each GEPA cycle */}
314
+ {latestDiff && (
315
+ <div>
316
+ <div className="flex items-center gap-1.5 mb-2">
317
+ <GitCommitHorizontal size={11} className="text-violet-400" />
318
+ <span className="text-[10px] text-gray-500 font-semibold uppercase tracking-wider">
319
+ Prompt diff β€” Gen {latestDiff.from} β†’ {latestDiff.to}
320
+ </span>
321
+ </div>
322
+ <GithubDiff fromIdx={latestDiff.from} toIdx={latestDiff.to} />
323
+ </div>
324
+ )}
325
+ </div>
326
+ )
327
+ }
328
+
329
+ // ─── SQL highlighter ─────────────────────────────────────────────────────────
330
 
331
  function HighlightSQL({ sql }: { sql: string }) {
332
+ const re = /\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|ON|GROUP BY|ORDER BY|HAVING|LIMIT|AS|AND|OR|NOT|IN|IS|NULL|SUM|AVG|COUNT|ROUND|DISTINCT|DESC|ASC|strftime|JULIANDAY)\b/gi
333
  const parts: React.ReactNode[] = []
334
+ let last = 0; let match: RegExpExecArray | null
335
+ const r = new RegExp(re.source, 'gi')
336
+ while ((match = r.exec(sql)) !== null) {
 
337
  if (match.index > last) parts.push(<span key={`t${last}`}>{sql.slice(last, match.index)}</span>)
338
  parts.push(<span key={`k${match.index}`} className="text-violet-300 font-semibold">{match[0]}</span>)
339
  last = match.index + match[0].length
340
  }
341
+ if (last < sql.length) parts.push(<span key="end">{sql.slice(last)}</span>)
342
  return <>{parts}</>
343
  }
344
 
345
+ // ─── Bubble types ─────────────────────────────────────────────────────────────
346
+
347
+ interface BaseBubble { id: string }
348
+ interface UserBubble extends BaseBubble { type: 'user'; text: string }
349
+ interface ThinkingBubble extends BaseBubble { type: 'thinking'; label: string }
350
+ interface SqlStreamBubble extends BaseBubble { type: 'sql_stream'; sql: string; attempt: number }
351
+ interface SqlErrBubble extends BaseBubble { type: 'sql_err'; sql: string; error: string; errorClass: string; rlAction: string; reward: number; attempt: number }
352
+ interface SqlOkBubble extends BaseBubble { type: 'sql_ok'; sql: string; rows: Record<string, string | number>[]; reward: number; attempt: number; firstTry: boolean }
353
+ interface GepaBubble extends BaseBubble { type: 'gepa'; fromGen: number; toGen: number; scoreFrom: number; scoreTo: number }
354
+ interface GroupBubble extends BaseBubble { type: 'group'; question: string; success: boolean; attempts: number; children: BubbleData[] }
355
+ type BubbleData = UserBubble | ThinkingBubble | SqlStreamBubble | SqlErrBubble | SqlOkBubble | GepaBubble | GroupBubble
356
+
357
+ let _id = 0
358
+ const uid = () => `b${++_id}`
359
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
360
+
361
+ // ─── Bubble renderer ─────────────────────────────────────────────────────────
362
 
363
  function Bubble({ b }: { b: BubbleData }) {
364
  const [open, setOpen] = useState(false)
365
 
366
  if (b.type === 'user') return (
367
  <div className="flex justify-end">
368
+ <div className="max-w-[78%] bg-violet-600/20 border border-violet-500/25 rounded-2xl rounded-tr-sm px-4 py-2.5">
369
  <p className="text-sm text-white">{b.text}</p>
370
  </div>
371
  </div>
372
  )
373
 
374
  if (b.type === 'thinking') return (
375
+ <div className="flex items-center gap-2 px-1 text-xs text-gray-500">
376
  <Loader2 size={11} className="animate-spin text-violet-400 shrink-0" />
377
  {b.label}
378
  </div>
 
382
  <div className="border border-white/[0.06] rounded-xl overflow-hidden">
383
  <div className="px-3 py-1.5 bg-white/[0.02] flex items-center gap-2">
384
  <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">SQL</span>
385
+ <span className="text-[10px] text-gray-600">attempt {b.attempt}</span>
386
  <Loader2 size={9} className="animate-spin text-violet-400 ml-auto" />
387
  </div>
388
+ <pre className="px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap" style={{ color: 'rgba(221,214,254,0.8)', background: 'rgba(139,92,246,0.05)' }}>
389
  <HighlightSQL sql={b.sql} />
390
  <span className="inline-block w-0.5 h-[1em] bg-violet-400 animate-pulse align-bottom ml-0.5" />
391
  </pre>
 
397
  <div className="border border-white/[0.06] rounded-xl overflow-hidden">
398
  <div className="px-3 py-1.5 bg-white/[0.02] flex items-center gap-2">
399
  <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">SQL</span>
400
+ <span className="text-[10px] text-gray-600">attempt {b.attempt}</span>
401
  </div>
402
+ <pre className="px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap" style={{ color: 'rgba(221,214,254,0.8)', background: 'rgba(139,92,246,0.05)' }}>
403
  <HighlightSQL sql={b.sql} />
404
  </pre>
405
  </div>
406
+ <div className="flex items-center gap-2 flex-wrap bg-red-500/8 border border-red-500/20 rounded-xl px-3 py-2">
407
+ <XCircle size={11} className="text-red-400 shrink-0" />
408
+ <span className="text-xs text-red-300 flex-1">{b.error}</span>
409
+ <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-red-500/15 text-red-400 border border-red-500/20">{b.errorClass}</span>
410
+ <span className="flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full border border-orange-500/30 bg-orange-500/10 text-orange-400">
411
+ <Zap size={8} />{b.rlAction}
412
+ </span>
413
+ <span className="text-[11px] font-bold text-red-400">{b.reward.toFixed(2)}</span>
 
 
 
 
 
 
414
  </div>
415
  </div>
416
  )
 
421
  <div className="px-3 py-1.5 bg-white/[0.02] flex items-center gap-2">
422
  <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">SQL</span>
423
  {b.firstTry && (
424
+ <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 border border-green-500/25 text-green-400 font-semibold">first try βœ“</span>
425
  )}
426
+ <span className="ml-auto text-[11px] font-bold text-green-400">+{b.reward.toFixed(2)}</span>
427
  </div>
428
+ <pre className="px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap" style={{ color: 'rgba(221,214,254,0.8)', background: 'rgba(139,92,246,0.05)' }}>
429
  <HighlightSQL sql={b.sql} />
430
  </pre>
431
  </div>
432
+ <div className="flex items-center gap-1.5 text-[10px] px-0.5">
433
  <CheckCircle2 size={11} className="text-green-400" />
434
  <span className="text-green-400 font-semibold">Success</span>
435
+ <span className="text-gray-600">Β· {b.rows.length}+ rows returned</span>
 
436
  </div>
 
437
  <div className="rounded-xl border border-white/[0.06] overflow-hidden text-[10px]">
438
+ <table className="w-full">
439
+ <thead>
440
+ <tr className="bg-white/[0.03]">
441
+ {Object.keys(b.rows[0] ?? {}).map((k) => (
442
+ <th key={k} className="px-2 py-1.5 text-left font-semibold text-gray-500 whitespace-nowrap">{k}</th>
443
+ ))}
444
+ </tr>
445
+ </thead>
446
+ <tbody>
447
+ {b.rows.map((row, i) => (
448
+ <tr key={i} className={i % 2 === 0 ? 'bg-white/[0.01]' : ''}>
449
+ {Object.values(row).map((v, j) => (
450
+ <td key={j} className="px-2 py-1 text-gray-300 whitespace-nowrap">{String(v)}</td>
451
  ))}
452
  </tr>
453
+ ))}
454
+ </tbody>
455
+ </table>
 
 
 
 
 
 
 
 
 
456
  </div>
457
  </div>
458
  )
 
460
  if (b.type === 'gepa') return (
461
  <div className="border border-violet-500/25 rounded-2xl overflow-hidden bg-violet-500/5">
462
  <div className="px-4 py-3 flex items-center gap-2 border-b border-violet-500/15">
463
+ <Sparkles size={12} className="text-violet-400" />
464
+ <span className="text-xs font-semibold text-violet-300">GEPA β€” System Prompt Evolved</span>
465
+ <span className="ml-auto text-[10px] text-violet-400/60">Gen {b.fromGen} β†’ {b.toGen}</span>
466
  </div>
467
+ <div className="px-4 py-3 flex items-center gap-6">
468
+ <div className="text-center">
469
+ <div className="text-[10px] text-gray-600 mb-0.5">Before</div>
470
+ <div className="text-xl font-bold text-orange-400">{(b.scoreFrom * 100).toFixed(0)}%</div>
471
  </div>
472
+ <div className="flex-1 flex flex-col items-center">
473
+ <div className="text-xs text-violet-400 mb-1">↑ improved</div>
474
+ <div className="w-full h-0.5 bg-gradient-to-r from-orange-400 to-green-400 rounded-full" />
 
475
  </div>
476
+ <div className="text-center">
477
+ <div className="text-[10px] text-gray-600 mb-0.5">After</div>
478
+ <div className="text-xl font-bold text-green-400">{(b.scoreTo * 100).toFixed(0)}%</div>
479
  </div>
480
  </div>
481
+ <div className="px-4 pb-3">
482
+ <div className="text-[10px] text-gray-600 mb-1.5 flex items-center gap-1.5">
483
+ <GitCommitHorizontal size={10} />
484
+ Prompt diff (see sidebar for full view)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  </div>
486
+ <div className="rounded-lg border border-white/[0.05] overflow-hidden text-[10px] font-mono max-h-24 overflow-y-auto">
487
+ {diffPrompts(b.fromGen, b.toGen).filter((l) => l.type !== 'same').map((line, i) => (
488
+ <div key={i} className={`flex gap-2 px-2 py-0.5 ${line.type === 'add' ? 'bg-green-500/10' : 'bg-red-500/10'}`}>
489
+ <span className={`shrink-0 ${line.type === 'add' ? 'text-green-400' : 'text-red-400'}`}>{line.type === 'add' ? '+' : '-'}</span>
490
+ <span className={line.type === 'add' ? 'text-green-300' : 'text-red-300'}>{line.text}</span>
491
+ </div>
492
+ ))}
 
 
 
 
 
 
 
 
 
 
493
  </div>
494
  </div>
495
+ </div>
496
+ )
497
 
498
+ if (b.type === 'group') return (
499
+ <div className="border border-white/[0.06] rounded-2xl overflow-hidden">
500
+ <button
501
+ onClick={() => setOpen((v) => !v)}
502
+ className="w-full flex items-center gap-2 px-3 py-2.5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors text-left"
503
+ >
504
+ {b.success
505
+ ? <CheckCircle2 size={12} className="text-green-400 shrink-0" />
506
+ : <XCircle size={12} className="text-red-400 shrink-0" />}
507
+ <span className="text-xs text-gray-300 flex-1 truncate">{b.question}</span>
508
+ <span className="text-[10px] text-gray-600">{b.attempts} attempt{b.attempts !== 1 ? 's' : ''}</span>
509
+ {open ? <ChevronUp size={11} className="text-gray-600 shrink-0" /> : <ChevronDown size={11} className="text-gray-600 shrink-0" />}
510
+ </button>
511
+ <AnimatePresence>
512
+ {open && (
513
+ <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.15 }} className="overflow-hidden">
514
+ <div className="p-3 flex flex-col gap-2.5 border-t border-white/[0.04]">
515
+ {b.children.map((c) => <Bubble key={c.id} b={c} />)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  </div>
517
+ </motion.div>
518
+ )}
519
+ </AnimatePresence>
 
 
 
520
  </div>
521
  )
 
522
 
523
+ return null
 
 
 
524
  }
525
 
526
+ // ─── DemoMode ────────────────────────────────────────────────────────────────
527
+
528
+ export function DemoMode({ onClose }: { onClose: () => void }) {
529
  const [bubbles, setBubbles] = useState<BubbleData[]>([])
530
+ const [appState, setAppState] = useState<'idle' | 'running' | 'done'>('idle')
531
  const [gen, setGen] = useState(0)
532
  const [score, setScore] = useState(SCORES[0])
533
+ const [rewardPoints, setRewardPoints] = useState<RewardPoint[]>([])
534
+ const [latestDiff, setLatestDiff] = useState<{ from: number; to: number } | null>(null)
535
+ const stepRef = useRef(0)
536
  const cancel = useRef(false)
537
  const bottomRef = useRef<HTMLDivElement>(null)
538
 
539
+ const scroll = useCallback(() => {
540
+ setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 60)
541
  }, [])
542
 
543
+ const push = useCallback((b: BubbleData) => setBubbles((p) => [...p, b]), [])
 
 
544
 
545
+ const addReward = useCallback((reward: number) => {
546
+ stepRef.current += 1
547
+ setRewardPoints((p) => [...p, { step: stepRef.current, reward }])
548
  }, [])
549
 
 
550
  const typeUser = useCallback(async (text: string) => {
551
  const id = uid()
552
  push({ id, type: 'user', text: '' })
553
  for (let i = 1; i <= text.length; i++) {
554
  if (cancel.current) return
555
+ setBubbles((p) => p.map((b) => b.id === id ? { ...b, text: text.slice(0, i) } : b))
556
+ await sleep(32 + Math.random() * 22)
557
  }
558
+ scroll(); await sleep(300)
559
+ }, [push, scroll])
 
560
 
 
561
  const streamSQL = useCallback(async (sql: string, attempt: number) => {
562
  const id = uid()
563
  push({ id, type: 'sql_stream', sql: '', attempt })
 
565
  for (const line of sql.split('\n')) {
566
  if (cancel.current) return
567
  built += (built ? '\n' : '') + line
568
+ setBubbles((p) => p.map((b) => b.id === id ? { ...b, sql: built } : b))
569
+ await sleep(85 + Math.random() * 70)
570
  }
571
+ scroll(); await sleep(200)
572
+ setBubbles((p) => p.filter((b) => b.id !== id))
573
+ }, [push, scroll])
 
 
574
 
 
575
  const playQuery = useCallback(async (def: QueryDef): Promise<BubbleData[]> => {
576
  const children: BubbleData[] = []
 
577
  await typeUser(def.question)
578
 
579
  for (let i = 0; i < def.attempts.length; i++) {
580
  if (cancel.current) return children
581
  const att = def.attempts[i]
 
582
 
583
+ // Thinking
584
+ const thId = uid()
585
+ push({ id: thId, type: 'thinking', label: i === 0 ? 'Generating SQL…' : 'Applying repair strategy…' })
586
+ scroll(); await sleep(750)
 
587
  if (cancel.current) return children
588
+ setBubbles((p) => p.filter((b) => b.id !== thId))
589
 
590
+ await streamSQL(att.sql, i + 1)
591
  if (cancel.current) return children
592
 
593
+ addReward(att.reward)
594
+
595
  if (att.error) {
596
+ const eb: SqlErrBubble = { id: uid(), type: 'sql_err', sql: att.sql, error: att.error, errorClass: att.errorClass ?? 'OTHER', rlAction: att.rlAction ?? 'REWRITE_FULL', reward: att.reward, attempt: i + 1 }
597
+ push(eb); children.push(eb); scroll(); await sleep(900)
 
 
 
 
 
 
 
 
 
 
598
  } else {
599
+ const ob: SqlOkBubble = { id: uid(), type: 'sql_ok', sql: att.sql, rows: att.rows ?? [], reward: att.reward, attempt: i + 1, firstTry: i === 0 }
600
+ push(ob); children.push(ob); scroll(); await sleep(1100)
 
 
 
 
 
 
 
 
 
 
 
601
  }
602
  }
 
603
  return children
604
+ }, [push, scroll, typeUser, streamSQL, addReward])
605
 
 
606
  const collapseQuery = useCallback((def: QueryDef, children: BubbleData[]) => {
607
+ const lastAtt = def.attempts[def.attempts.length - 1]
 
 
 
608
  setBubbles((prev) => {
609
+ const userIdx = [...prev].reverse().findIndex((b) => b.type === 'user' && (b as UserBubble).text === def.question)
 
 
 
610
  if (userIdx < 0) return prev
611
  const fromIdx = prev.length - 1 - userIdx
612
+ const group: GroupBubble = { id: uid(), type: 'group', question: def.question, success: !lastAtt.error, attempts: def.attempts.length, children }
 
 
 
 
 
 
 
 
 
 
 
613
  return [...prev.slice(0, fromIdx), group]
614
  })
615
  }, [])
616
 
 
617
  const playGepa = useCallback(async (fromGen: number, toGen: number) => {
618
+ const steps = ['Analyzing failure patterns…', 'Identifying missing rules from errors…', 'Rewriting system prompt…', 'Benchmarking candidate prompt…']
619
+ for (const label of steps) {
 
 
 
 
 
 
 
 
620
  if (cancel.current) return
621
+ setBubbles((p) => {
622
+ const last = p[p.length - 1]
623
+ if (last?.type === 'thinking') return [...p.slice(0, -1), { id: last.id, type: 'thinking' as const, label }]
624
+ return [...p, { id: uid(), type: 'thinking' as const, label }]
 
 
625
  })
626
+ scroll(); await sleep(1050)
 
627
  }
628
+ setBubbles((p) => p.filter((b) => b.type !== 'thinking'))
 
629
 
630
  // Animate score
631
+ const from = SCORES[fromGen], to = SCORES[toGen]
632
+ for (let i = 0; i <= 45; i++) {
 
633
  if (cancel.current) return
634
+ setScore(from + (to - from) * (i / 45))
635
+ await sleep(18)
636
  }
637
  setGen(toGen)
638
+ setLatestDiff({ from: fromGen, to: toGen })
639
 
 
640
  push({ id: uid(), type: 'gepa', fromGen, toGen, scoreFrom: from, scoreTo: to })
641
+ scroll(); await sleep(1000)
642
+ }, [push, scroll])
 
 
643
 
644
  const autoPlay = useCallback(async () => {
645
  cancel.current = false
646
+ setBubbles([]); setGen(0); setScore(SCORES[0]); setRewardPoints([]); setLatestDiff(null)
647
+ stepRef.current = 0; setAppState('running')
648
+ await sleep(300)
 
 
649
 
 
650
  for (const id of ROUND_1) {
651
  if (cancel.current) break
652
+ const children = await playQuery(QUERIES[id])
 
653
  if (cancel.current) break
654
+ await sleep(350); collapseQuery(QUERIES[id], children); await sleep(600)
 
 
655
  }
656
+ if (!cancel.current) { await playGepa(0, 1); await sleep(500) }
657
 
 
 
 
 
 
 
658
  for (const id of ROUND_2) {
659
  if (cancel.current) break
660
+ const children = await playQuery(QUERIES[id])
 
661
  if (cancel.current) break
662
+ await sleep(350); collapseQuery(QUERIES[id], children); await sleep(600)
 
 
663
  }
664
+ if (!cancel.current) { await playGepa(1, 2); await sleep(500) }
665
 
666
+ for (const id of ROUND_3) {
667
+ if (cancel.current) break
668
+ const children = await playQuery(QUERIES[id])
669
+ if (cancel.current) break
670
+ await sleep(350); collapseQuery(QUERIES[id], children); await sleep(600)
671
  }
 
672
 
673
+ if (!cancel.current) setAppState('done')
674
+ }, [playQuery, collapseQuery, playGepa])
 
 
 
 
 
 
 
 
 
 
675
 
676
+ useEffect(() => () => { cancel.current = true }, [])
 
 
677
 
678
  return (
679
  <motion.div
680
+ initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
 
 
681
  className="fixed inset-0 z-[100] flex flex-col"
682
  style={{ background: 'var(--bg-primary)' }}
683
  >
684
+ {/* Header */}
685
+ <div className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-white/[0.06]" style={{ background: 'var(--bg-secondary)' }}>
 
 
 
686
  <div className="flex items-center gap-3">
687
  <div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-violet-500/15 border border-violet-500/25">
688
+ <Play size={9} className="text-violet-400" fill="currentColor" />
689
+ <span className="text-[11px] font-semibold text-violet-300">Demo</span>
690
  </div>
691
+ <span className="text-xs text-gray-600 hidden sm:block">
692
+ RL repair loop Β· GEPA prompt evolution Β· 42% β†’ 91%
693
  </span>
694
  </div>
695
+ <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/5 text-gray-500 hover:text-white transition-colors">
696
+ <X size={16} />
697
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  </div>
699
 
700
  {/* Body */}
701
  <div className="flex flex-1 overflow-hidden">
702
+ {/* Chat */}
703
  <div className="flex-1 flex flex-col overflow-hidden">
704
  <div className="flex-1 overflow-y-auto px-4 py-4">
705
  {appState === 'idle' ? (
706
+ <div className="flex flex-col items-center justify-center h-full gap-5 text-center px-6">
707
+ <div className="w-14 h-14 rounded-2xl flex items-center justify-center" style={{ background: 'linear-gradient(135deg,#3b0764,#1e3a5f)', boxShadow: '0 12px 32px rgba(139,92,246,0.3)' }}>
708
+ <Play size={24} className="text-white ml-0.5" fill="currentColor" />
 
 
 
 
709
  </div>
710
  <div>
711
+ <h2 className="text-base font-bold text-white mb-1.5">SQL Agent OpenEnv β€” Live Demo</h2>
712
+ <p className="text-sm text-gray-500 max-w-sm leading-relaxed">
713
+ Watch the agent fail, self-repair using LinUCB, then improve through two GEPA prompt evolution cycles.
 
714
  </p>
715
  </div>
716
+ <div className="flex flex-col gap-1.5 text-xs text-gray-600 max-w-xs text-left">
717
+ {['Round 1 β€” simple queries, RL repairs table/column errors', 'GEPA cycle 1 β€” prompt learns alias rules (42%β†’74%)', 'Round 2 β€” join queries, ambiguous column errors repaired', 'GEPA cycle 2 β€” prompt learns aggregation rules (74%β†’91%)', 'Round 3 β€” same queries, first-try success'].map((s, i) => (
718
+ <div key={i} className="flex items-start gap-2">
719
+ <div className="w-4 h-4 rounded-full bg-violet-500/20 border border-violet-500/30 flex items-center justify-center text-[9px] text-violet-400 font-bold shrink-0 mt-0.5">{i + 1}</div>
 
 
 
 
 
720
  {s}
721
  </div>
722
  ))}
723
  </div>
724
  <button
725
+ onClick={() => void autoPlay()}
726
+ className="flex items-center gap-2 px-6 py-3 rounded-2xl font-semibold text-sm text-white active:scale-95 transition-transform"
727
  style={{ background: 'linear-gradient(135deg,#7c3aed,#2563eb)', boxShadow: '0 8px 24px rgba(124,58,237,0.4)' }}
728
  >
729
+ <Play size={13} fill="currentColor" />
730
  Start Demo
731
  </button>
732
  </div>
 
734
  <div className="flex flex-col gap-4 max-w-2xl mx-auto">
735
  <AnimatePresence initial={false}>
736
  {bubbles.map((b) => (
737
+ <motion.div key={b.id} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18 }}>
 
 
 
 
 
738
  <Bubble b={b} />
739
  </motion.div>
740
  ))}
741
  </AnimatePresence>
742
+
743
  {appState === 'done' && (
744
+ <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="border border-green-500/25 rounded-2xl p-5 bg-green-500/5 text-center mt-2">
745
+ <div className="text-3xl font-bold text-green-400 mb-1 tabular-nums">91%</div>
746
+ <div className="text-sm font-semibold text-white mb-1">Demo complete</div>
747
+ <div className="text-xs text-gray-500 max-w-xs mx-auto leading-relaxed">
748
+ Agent improved from 42% β†’ 91% accuracy through RL-driven repair strategies and two GEPA prompt evolution cycles.
 
 
 
 
749
  </div>
 
 
 
 
 
 
 
750
  </motion.div>
751
  )}
752
  <div ref={bottomRef} />
 
755
  </div>
756
  </div>
757
 
758
+ {/* Right panel */}
759
+ <aside className="hidden lg:flex flex-col w-72 border-l border-white/[0.06] overflow-hidden shrink-0" style={{ background: 'var(--bg-secondary)' }}>
760
+ <RightPanel gen={gen} score={score} rewardPoints={rewardPoints} latestDiff={latestDiff} />
 
 
 
761
  </aside>
762
  </div>
763
  </motion.div>