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

feat: add Demo button with scripted autoplay showcase

Browse files
frontend/src/App.tsx CHANGED
@@ -8,6 +8,7 @@ import { ChatPanel } from './components/ChatPanel'
8
  import { BenchmarkPanel } from './components/BenchmarkPanel'
9
  import { ERDiagram } from './components/ERDiagram'
10
  import { RightSidebar } from './components/RightSidebar'
 
11
  import { useStore } from './store/useStore'
12
  import { fetchInit } from './lib/api'
13
 
@@ -23,6 +24,7 @@ export default function App() {
23
  const [activeTab, setActiveTab] = useState<Tab>('chat')
24
  const [leftOpen, setLeftOpen] = useState(false)
25
  const [rightOpen, setRightOpen] = useState(false)
 
26
 
27
  const { theme, setDbSeeded, setTables, setSchemaGraph } = useStore()
28
 
@@ -71,8 +73,13 @@ export default function App() {
71
  <Header
72
  onToggleLeft={() => { setLeftOpen((v) => !v); setRightOpen(false) }}
73
  onToggleRight={() => { setRightOpen((v) => !v); setLeftOpen(false) }}
 
74
  />
75
 
 
 
 
 
76
  <div className="flex flex-1 overflow-hidden relative">
77
  {/* Overlay backdrop (mobile) */}
78
  {(leftOpen || rightOpen) && (
 
8
  import { BenchmarkPanel } from './components/BenchmarkPanel'
9
  import { ERDiagram } from './components/ERDiagram'
10
  import { RightSidebar } from './components/RightSidebar'
11
+ import { DemoMode } from './components/DemoMode'
12
  import { useStore } from './store/useStore'
13
  import { fetchInit } from './lib/api'
14
 
 
24
  const [activeTab, setActiveTab] = useState<Tab>('chat')
25
  const [leftOpen, setLeftOpen] = useState(false)
26
  const [rightOpen, setRightOpen] = useState(false)
27
+ const [demoOpen, setDemoOpen] = useState(false)
28
 
29
  const { theme, setDbSeeded, setTables, setSchemaGraph } = useStore()
30
 
 
73
  <Header
74
  onToggleLeft={() => { setLeftOpen((v) => !v); setRightOpen(false) }}
75
  onToggleRight={() => { setRightOpen((v) => !v); setLeftOpen(false) }}
76
+ onDemo={() => setDemoOpen(true)}
77
  />
78
 
79
+ <AnimatePresence>
80
+ {demoOpen && <DemoMode onClose={() => setDemoOpen(false)} />}
81
+ </AnimatePresence>
82
+
83
  <div className="flex flex-1 overflow-hidden relative">
84
  {/* Overlay backdrop (mobile) */}
85
  {(leftOpen || rightOpen) && (
frontend/src/components/DemoMode.tsx ADDED
@@ -0,0 +1,908 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
57
+ errorClass?: string
58
+ rlAction?: string
59
+ reward: number
60
+ rows?: Record<string, string | number>[]
61
+ }
62
+
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>
285
+ )
286
+
287
+ if (b.type === 'sql_stream') return (
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>
298
+ </div>
299
+ )
300
+
301
+ if (b.type === 'sql_err') return (
302
+ <div className="flex flex-col gap-1.5">
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
+ )
329
+
330
+ if (b.type === 'sql_ok') return (
331
+ <div className="flex flex-col gap-1.5">
332
+ <div className="border border-white/[0.06] rounded-xl overflow-hidden">
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
+ )
374
+
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 })
576
+ let built = ''
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>
858
+ ) : (
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} />
893
+ </div>
894
+ )}
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>
907
+ )
908
+ }
frontend/src/components/Header.tsx CHANGED
@@ -1,10 +1,11 @@
1
- import { Database, Sun, Moon, PanelLeftOpen, PanelRightOpen, Cpu } from 'lucide-react'
2
  import { useStore } from '../store/useStore'
3
  import type { Difficulty } from '../lib/types'
4
 
5
  interface HeaderProps {
6
  onToggleLeft: () => void
7
  onToggleRight: () => void
 
8
  }
9
 
10
  const DIFFICULTIES: { id: Difficulty; label: string; color: string }[] = [
@@ -13,7 +14,7 @@ const DIFFICULTIES: { id: Difficulty; label: string; color: string }[] = [
13
  { id: 'hard', label: 'Hard', color: 'text-red-400 border-red-500/30 bg-red-500/10' },
14
  ]
15
 
16
- export function Header({ onToggleLeft, onToggleRight }: HeaderProps) {
17
  const { theme, toggleTheme, dbSeeded, taskDifficulty, setTaskDifficulty } = useStore()
18
 
19
  return (
@@ -87,6 +88,16 @@ export function Header({ onToggleLeft, onToggleRight }: HeaderProps) {
87
  ))}
88
  </div>
89
 
 
 
 
 
 
 
 
 
 
 
90
  {/* Theme toggle */}
91
  <button
92
  onClick={toggleTheme}
 
1
+ import { Database, Sun, Moon, PanelLeftOpen, PanelRightOpen, Cpu, Play } from 'lucide-react'
2
  import { useStore } from '../store/useStore'
3
  import type { Difficulty } from '../lib/types'
4
 
5
  interface HeaderProps {
6
  onToggleLeft: () => void
7
  onToggleRight: () => void
8
+ onDemo: () => void
9
  }
10
 
11
  const DIFFICULTIES: { id: Difficulty; label: string; color: string }[] = [
 
14
  { id: 'hard', label: 'Hard', color: 'text-red-400 border-red-500/30 bg-red-500/10' },
15
  ]
16
 
17
+ export function Header({ onToggleLeft, onToggleRight, onDemo }: HeaderProps) {
18
  const { theme, toggleTheme, dbSeeded, taskDifficulty, setTaskDifficulty } = useStore()
19
 
20
  return (
 
88
  ))}
89
  </div>
90
 
91
+ {/* Demo button */}
92
+ <button
93
+ onClick={onDemo}
94
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold text-white transition-all active:scale-95"
95
+ style={{ background: 'linear-gradient(135deg,#7c3aed,#2563eb)', boxShadow: '0 4px 12px rgba(124,58,237,0.35)' }}
96
+ >
97
+ <Play size={10} fill="currentColor" />
98
+ <span>Demo</span>
99
+ </button>
100
+
101
  {/* Theme toggle */}
102
  <button
103
  onClick={toggleTheme}