create-interview-cockpit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,827 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ X,
4
+ RotateCcw,
5
+ Save,
6
+ ChevronDown,
7
+ ChevronRight,
8
+ Check,
9
+ Loader2,
10
+ Plus,
11
+ Trash2,
12
+ } from "lucide-react";
13
+ import { useStore } from "../store";
14
+ import type { AiSettings, PromptGroup } from "../api";
15
+
16
+ // ── Committed baseline — mirrors data/ai-settings.json ───────────────────────
17
+ const BASELINE: AiSettings = {
18
+ systemPrompt:
19
+ "You are a senior engineering interview coach.\n\nHighest priority: follow the user's explicit response preferences and current conversation context. If they conflict with your default teaching behavior, the user's preference wins.\nExplain clearly, accurately, and practically.\nOnly include Mermaid diagrams, code blocks, or tables when the user explicitly asks for them or when they materially improve the answer.\nIf you show code, use a fenced code block with the correct language.\n\nMermaid syntax rules (follow strictly):\n- Wrap node labels in quotes when they contain special characters: A[\"Microservice A (Producer)\"]\n- Edge labels use |text| syntax: A -->|sends message| B\n- Never put parentheses or brackets inside [] without quoting the label\n- Use simple node IDs (letters/numbers) and put descriptive text in the label\n\nYou can produce animated diagrams using ```viz blocks for flows, step-through walkthroughs, or any explanation that benefits from animation. Call getVizGuide() first to get the full spec reference before writing a viz block.",
20
+ responseProfiles: {
21
+ concise: { maxOutputTokens: 1000, maxSteps: 3 },
22
+ moderate: { maxOutputTokens: 1000, maxSteps: 5 },
23
+ normal: { maxOutputTokens: 3000, maxSteps: 5 },
24
+ },
25
+ vizGuide: "",
26
+ promptGroups: {
27
+ length: {
28
+ label: "Response Length",
29
+ description:
30
+ "Appended to the user message when the selected length changes.",
31
+ default: "normal",
32
+ options: {
33
+ concise:
34
+ "Keep the response concise. Aim for roughly 300 characters of text when possible. These limits do not apply to mermaid diagrams. You can generate as many as you want to explain the solution effectively. Prioritize diagrams over text.",
35
+ moderate:
36
+ "Keep the response moderately detailed. Aim for roughly 550 characters of text when possible.",
37
+ normal:
38
+ "Use a fuller answer with enough context to explain the idea clearly.",
39
+ },
40
+ },
41
+ style: {
42
+ label: "Response Style",
43
+ description:
44
+ "Appended to the user message when the selected style changes.",
45
+ default: "prose",
46
+ options: {
47
+ prose:
48
+ "Use natural prose with short paragraphs. Avoid bullet lists and numbered lists unless I explicitly ask for them.",
49
+ bullets: "Use bullet points and short lists as the main format.",
50
+ structured:
51
+ "Use structured sections with headings and numbered steps when helpful.",
52
+ },
53
+ },
54
+ audience: {
55
+ label: "Response Audience",
56
+ description:
57
+ "Appended to the user message when the selected audience changes.",
58
+ default: "normal",
59
+ options: {
60
+ normal: "",
61
+ beginner:
62
+ "When using technical terms or abbreviations, immediately expand their meaning in square brackets right after the term — e.g. 'TCP [Transmission Control Protocol — a connection-oriented transport protocol]' or 'idempotent [an operation that produces the same result no matter how many times it is applied]'. Do this throughout your response so I never need to look anything up.",
63
+ },
64
+ },
65
+ },
66
+ };
67
+
68
+ // ── Helpers ───────────────────────────────────────────────────────────────────
69
+
70
+ function Section({
71
+ title,
72
+ children,
73
+ defaultOpen = true,
74
+ trailingAction,
75
+ }: {
76
+ title: string;
77
+ children: React.ReactNode;
78
+ defaultOpen?: boolean;
79
+ trailingAction?: React.ReactNode;
80
+ }) {
81
+ const [open, setOpen] = useState(defaultOpen);
82
+ return (
83
+ <div className="border border-slate-700 rounded-lg overflow-hidden">
84
+ <button
85
+ type="button"
86
+ onClick={() => setOpen((v) => !v)}
87
+ className="w-full flex items-center justify-between px-4 py-3 bg-slate-800/60 hover:bg-slate-800 text-left transition-colors"
88
+ >
89
+ <span className="text-sm font-medium text-slate-200">{title}</span>
90
+ <div className="flex items-center gap-2">
91
+ {trailingAction && (
92
+ <span
93
+ onClick={(e) => e.stopPropagation()}
94
+ className="flex items-center"
95
+ >
96
+ {trailingAction}
97
+ </span>
98
+ )}
99
+ {open ? (
100
+ <ChevronDown className="w-4 h-4 text-slate-400 shrink-0" />
101
+ ) : (
102
+ <ChevronRight className="w-4 h-4 text-slate-400 shrink-0" />
103
+ )}
104
+ </div>
105
+ </button>
106
+ {open && <div className="p-4 space-y-4 bg-slate-900/40">{children}</div>}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function Label({ children }: { children: React.ReactNode }) {
112
+ return (
113
+ <label className="block text-xs font-medium text-slate-400 mb-1 uppercase tracking-wide">
114
+ {children}
115
+ </label>
116
+ );
117
+ }
118
+
119
+ function Textarea({
120
+ value,
121
+ onChange,
122
+ rows = 4,
123
+ mono = false,
124
+ }: {
125
+ value: string;
126
+ onChange: (v: string) => void;
127
+ rows?: number;
128
+ mono?: boolean;
129
+ }) {
130
+ return (
131
+ <textarea
132
+ rows={rows}
133
+ value={value}
134
+ onChange={(e) => onChange(e.target.value)}
135
+ className={`w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-500 resize-y ${mono ? "font-mono" : ""}`}
136
+ />
137
+ );
138
+ }
139
+
140
+ function NumberInput({
141
+ value,
142
+ onChange,
143
+ min,
144
+ max,
145
+ step = 1,
146
+ }: {
147
+ value: number;
148
+ onChange: (v: number) => void;
149
+ min?: number;
150
+ max?: number;
151
+ step?: number;
152
+ }) {
153
+ return (
154
+ <input
155
+ type="number"
156
+ value={value}
157
+ min={min}
158
+ max={max}
159
+ step={step}
160
+ onChange={(e) => onChange(Number(e.target.value))}
161
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
162
+ />
163
+ );
164
+ }
165
+
166
+ // ── Generic prompt-group section ───────────────────────────────────────────
167
+
168
+ function PromptGroupSection({
169
+ groupKey,
170
+ group,
171
+ onOptionChange,
172
+ onDefaultChange,
173
+ onAddOption,
174
+ onRemoveOption,
175
+ onRemoveGroup,
176
+ onMetaChange,
177
+ }: {
178
+ groupKey: string;
179
+ group: PromptGroup;
180
+ onOptionChange: (optKey: string, value: string) => void;
181
+ onDefaultChange: (optKey: string) => void;
182
+ onAddOption: (optKey: string, prompt: string) => void;
183
+ onRemoveOption: (optKey: string) => void;
184
+ onRemoveGroup: () => void;
185
+ onMetaChange: (field: "label" | "description", value: string) => void;
186
+ }) {
187
+ const [newOptKey, setNewOptKey] = useState("");
188
+ const [newOptPrompt, setNewOptPrompt] = useState("");
189
+ const [showAddOpt, setShowAddOpt] = useState(false);
190
+ const optionCount = Object.keys(group.options).length;
191
+
192
+ const handleAddOption = () => {
193
+ const key = newOptKey.trim();
194
+ if (!key || key in group.options) return;
195
+ onAddOption(key, newOptPrompt);
196
+ setNewOptKey("");
197
+ setNewOptPrompt("");
198
+ setShowAddOpt(false);
199
+ };
200
+
201
+ return (
202
+ <Section
203
+ title={group.label || groupKey}
204
+ trailingAction={
205
+ <button
206
+ type="button"
207
+ onClick={onRemoveGroup}
208
+ title="Delete this group"
209
+ className="p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
210
+ >
211
+ <Trash2 className="w-3.5 h-3.5" />
212
+ </button>
213
+ }
214
+ >
215
+ {/* Meta fields */}
216
+ <div className="grid grid-cols-2 gap-3">
217
+ <div>
218
+ <Label>Label (shown in bottom bar)</Label>
219
+ <input
220
+ type="text"
221
+ value={group.label}
222
+ onChange={(e) => onMetaChange("label", e.target.value)}
223
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
224
+ />
225
+ </div>
226
+ <div>
227
+ <Label>Description</Label>
228
+ <input
229
+ type="text"
230
+ value={group.description ?? ""}
231
+ onChange={(e) => onMetaChange("description", e.target.value)}
232
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
233
+ />
234
+ </div>
235
+ </div>
236
+
237
+ {/* Options */}
238
+ {Object.entries(group.options).map(([optKey, optValue]) => (
239
+ <div key={optKey} className="flex gap-3 items-start">
240
+ <button
241
+ type="button"
242
+ title="Set as default"
243
+ onClick={() => onDefaultChange(optKey)}
244
+ className={`mt-6 shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
245
+ group.default === optKey
246
+ ? "border-cyan-400 bg-cyan-400"
247
+ : "border-slate-600 hover:border-slate-400"
248
+ }`}
249
+ >
250
+ {group.default === optKey && (
251
+ <span className="w-1.5 h-1.5 rounded-full bg-slate-900" />
252
+ )}
253
+ </button>
254
+ <div className="flex-1">
255
+ <Label>
256
+ {optKey}
257
+ {group.default === optKey && (
258
+ <span className="ml-2 text-cyan-400 normal-case font-normal">
259
+ (default)
260
+ </span>
261
+ )}
262
+ </Label>
263
+ <Textarea
264
+ value={optValue}
265
+ onChange={(v) => onOptionChange(optKey, v)}
266
+ rows={optValue.length > 120 ? 3 : 2}
267
+ />
268
+ </div>
269
+ {optionCount > 1 && (
270
+ <button
271
+ type="button"
272
+ title="Remove option"
273
+ onClick={() => onRemoveOption(optKey)}
274
+ className="mt-6 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
275
+ >
276
+ <Trash2 className="w-3.5 h-3.5" />
277
+ </button>
278
+ )}
279
+ </div>
280
+ ))}
281
+
282
+ {/* Add option */}
283
+ {showAddOpt ? (
284
+ <div className="border border-slate-700 rounded-lg p-3 space-y-2 bg-slate-800/40">
285
+ <p className="text-xs font-medium text-slate-400">New option</p>
286
+ <div className="flex gap-2">
287
+ <div className="w-36">
288
+ <Label>Key (e.g. "verbose")</Label>
289
+ <input
290
+ type="text"
291
+ value={newOptKey}
292
+ onChange={(e) =>
293
+ setNewOptKey(
294
+ e.target.value.toLowerCase().replace(/\s+/g, "-"),
295
+ )
296
+ }
297
+ placeholder="key"
298
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
299
+ />
300
+ </div>
301
+ <div className="flex-1">
302
+ <Label>Prompt text</Label>
303
+ <input
304
+ type="text"
305
+ value={newOptPrompt}
306
+ onChange={(e) => setNewOptPrompt(e.target.value)}
307
+ placeholder="Appended to user message when selected"
308
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
309
+ />
310
+ </div>
311
+ </div>
312
+ <div className="flex gap-2">
313
+ <button
314
+ type="button"
315
+ onClick={handleAddOption}
316
+ disabled={!newOptKey.trim() || newOptKey in group.options}
317
+ className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
318
+ >
319
+ Add
320
+ </button>
321
+ <button
322
+ type="button"
323
+ onClick={() => {
324
+ setShowAddOpt(false);
325
+ setNewOptKey("");
326
+ setNewOptPrompt("");
327
+ }}
328
+ className="px-3 py-1 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
329
+ >
330
+ Cancel
331
+ </button>
332
+ </div>
333
+ </div>
334
+ ) : (
335
+ <button
336
+ type="button"
337
+ onClick={() => setShowAddOpt(true)}
338
+ className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
339
+ >
340
+ <Plus className="w-3.5 h-3.5" /> Add option
341
+ </button>
342
+ )}
343
+ </Section>
344
+ );
345
+ }
346
+
347
+ // ── Main modal ───────────────────────────────────────────────────────────────
348
+
349
+ export default function AiSettingsModal() {
350
+ const { aiSettings, saveAiSettings, closeSettings } = useStore();
351
+ const [draft, setDraft] = useState<AiSettings>(() =>
352
+ JSON.parse(JSON.stringify(aiSettings)),
353
+ );
354
+ const [saving, setSaving] = useState(false);
355
+ const [saved, setSaved] = useState(false);
356
+ const overlayRef = useRef<HTMLDivElement>(null);
357
+
358
+ // ── New group form state ─────────────────────
359
+ const [showNewGroupForm, setShowNewGroupForm] = useState(false);
360
+ const [newGroupKey, setNewGroupKey] = useState("");
361
+ const [newGroupLabel, setNewGroupLabel] = useState("");
362
+ const [newGroupDescription, setNewGroupDescription] = useState("");
363
+ const [newGroupOptions, setNewGroupOptions] = useState([
364
+ { key: "", prompt: "" },
365
+ ]);
366
+
367
+ function resetNewGroupForm() {
368
+ setShowNewGroupForm(false);
369
+ setNewGroupKey("");
370
+ setNewGroupLabel("");
371
+ setNewGroupDescription("");
372
+ setNewGroupOptions([{ key: "", prompt: "" }]);
373
+ }
374
+ // ───────────────────────────────────────────
375
+
376
+ // Sync draft if settings change externally (e.g. first fetch)
377
+ useEffect(() => {
378
+ setDraft(JSON.parse(JSON.stringify(aiSettings)));
379
+ }, [aiSettings]);
380
+
381
+ // Close on Escape
382
+ useEffect(() => {
383
+ const handler = (e: KeyboardEvent) => {
384
+ if (e.key === "Escape") closeSettings();
385
+ };
386
+ window.addEventListener("keydown", handler);
387
+ return () => window.removeEventListener("keydown", handler);
388
+ }, [closeSettings]);
389
+
390
+ // ── Patch helpers ─────────────────────────────────────────────
391
+ function patchTop<K extends keyof AiSettings>(key: K, value: AiSettings[K]) {
392
+ setDraft((d) => ({ ...d, [key]: value }));
393
+ }
394
+
395
+ function patchProfileField(
396
+ profile: string,
397
+ field: "maxOutputTokens" | "maxSteps",
398
+ value: number,
399
+ ) {
400
+ setDraft((d) => ({
401
+ ...d,
402
+ responseProfiles: {
403
+ ...d.responseProfiles,
404
+ [profile]: { ...d.responseProfiles[profile], [field]: value },
405
+ },
406
+ }));
407
+ }
408
+
409
+ function patchGroupOption(groupKey: string, optKey: string, value: string) {
410
+ setDraft((d) => ({
411
+ ...d,
412
+ promptGroups: {
413
+ ...d.promptGroups,
414
+ [groupKey]: {
415
+ ...d.promptGroups[groupKey],
416
+ options: { ...d.promptGroups[groupKey].options, [optKey]: value },
417
+ },
418
+ },
419
+ }));
420
+ }
421
+
422
+ function patchGroupDefault(groupKey: string, optKey: string) {
423
+ setDraft((d) => ({
424
+ ...d,
425
+ promptGroups: {
426
+ ...d.promptGroups,
427
+ [groupKey]: { ...d.promptGroups[groupKey], default: optKey },
428
+ },
429
+ }));
430
+ }
431
+
432
+ function patchGroupMeta(
433
+ groupKey: string,
434
+ field: "label" | "description",
435
+ value: string,
436
+ ) {
437
+ setDraft((d) => ({
438
+ ...d,
439
+ promptGroups: {
440
+ ...d.promptGroups,
441
+ [groupKey]: { ...d.promptGroups[groupKey], [field]: value },
442
+ },
443
+ }));
444
+ }
445
+
446
+ function addGroupOption(groupKey: string, optKey: string, prompt: string) {
447
+ setDraft((d) => {
448
+ const g = d.promptGroups[groupKey];
449
+ return {
450
+ ...d,
451
+ promptGroups: {
452
+ ...d.promptGroups,
453
+ [groupKey]: {
454
+ ...g,
455
+ options: { ...g.options, [optKey]: prompt },
456
+ },
457
+ },
458
+ };
459
+ });
460
+ }
461
+
462
+ function removeGroupOption(groupKey: string, optKey: string) {
463
+ setDraft((d) => {
464
+ const g = d.promptGroups[groupKey];
465
+ const newOptions = { ...g.options };
466
+ delete newOptions[optKey];
467
+ const firstKey = Object.keys(newOptions)[0] ?? "";
468
+ return {
469
+ ...d,
470
+ promptGroups: {
471
+ ...d.promptGroups,
472
+ [groupKey]: {
473
+ ...g,
474
+ options: newOptions,
475
+ default: g.default === optKey ? firstKey : g.default,
476
+ },
477
+ },
478
+ };
479
+ });
480
+ }
481
+
482
+ function removeGroup(groupKey: string) {
483
+ if (
484
+ !window.confirm(
485
+ `Delete the “${draft.promptGroups[groupKey]?.label || groupKey}” group? This will also remove it from the bottom bar.`,
486
+ )
487
+ )
488
+ return;
489
+ setDraft((d) => {
490
+ const newGroups = { ...d.promptGroups };
491
+ delete newGroups[groupKey];
492
+ return { ...d, promptGroups: newGroups };
493
+ });
494
+ }
495
+
496
+ function addNewGroup() {
497
+ const key = newGroupKey.trim().toLowerCase().replace(/\s+/g, "-");
498
+ if (!key || key in draft.promptGroups) return;
499
+ if (newGroupOptions.some((o) => !o.key.trim())) return;
500
+ const options = Object.fromEntries(
501
+ newGroupOptions.map((o) => [o.key.trim(), o.prompt]),
502
+ );
503
+ const firstKey = Object.keys(options)[0] ?? "";
504
+ setDraft((d) => ({
505
+ ...d,
506
+ promptGroups: {
507
+ ...d.promptGroups,
508
+ [key]: {
509
+ label: newGroupLabel.trim() || key,
510
+ description: newGroupDescription.trim() || undefined,
511
+ default: firstKey,
512
+ options,
513
+ },
514
+ },
515
+ }));
516
+ resetNewGroupForm();
517
+ }
518
+
519
+ // ── Actions ───────────────────────────────────────────────────
520
+ async function handleSave() {
521
+ if (saving) return;
522
+ setSaving(true);
523
+ try {
524
+ await saveAiSettings(draft);
525
+ setSaved(true);
526
+ setTimeout(() => setSaved(false), 2000);
527
+ } finally {
528
+ setSaving(false);
529
+ }
530
+ }
531
+
532
+ function handleReset() {
533
+ if (!window.confirm("Reset all settings to the committed baseline?"))
534
+ return;
535
+ setDraft(JSON.parse(JSON.stringify(BASELINE)));
536
+ }
537
+
538
+ // ── Render ────────────────────────────────────────────────────
539
+ const profileKeys = Object.keys(draft.responseProfiles);
540
+
541
+ return (
542
+ <div
543
+ ref={overlayRef}
544
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
545
+ onMouseDown={(e) => {
546
+ if (e.target === overlayRef.current) closeSettings();
547
+ }}
548
+ >
549
+ <div className="relative w-full max-w-3xl max-h-[90vh] flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl">
550
+ {/* Header */}
551
+ <div className="flex items-center justify-between px-6 py-4 border-b border-slate-700 shrink-0">
552
+ <h2 className="text-base font-semibold text-slate-100">
553
+ AI Settings
554
+ </h2>
555
+ <div className="flex items-center gap-2">
556
+ <button
557
+ type="button"
558
+ onClick={handleReset}
559
+ title="Reset to baseline defaults"
560
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
561
+ >
562
+ <RotateCcw className="w-3.5 h-3.5" />
563
+ Reset to defaults
564
+ </button>
565
+ <button
566
+ type="button"
567
+ onClick={closeSettings}
568
+ className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
569
+ >
570
+ <X className="w-4 h-4" />
571
+ </button>
572
+ </div>
573
+ </div>
574
+
575
+ {/* Scrollable body */}
576
+ <div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
577
+ {/* ── System Prompt ─────────────────────────────────── */}
578
+ <Section title="System Prompt">
579
+ <Textarea
580
+ value={draft.systemPrompt}
581
+ onChange={(v) => patchTop("systemPrompt", v)}
582
+ rows={8}
583
+ />
584
+ </Section>
585
+
586
+ {/* ── Response Profiles ─────────────────────────────── */}
587
+ <Section title="Response Profiles (token limits per length setting)">
588
+ <div className="grid grid-cols-3 gap-4">
589
+ {profileKeys.map((key) => (
590
+ <div key={key} className="space-y-3">
591
+ <p className="text-xs font-semibold text-cyan-400 capitalize">
592
+ {key}
593
+ </p>
594
+ <div>
595
+ <Label>Max Output Tokens</Label>
596
+ <NumberInput
597
+ value={draft.responseProfiles[key].maxOutputTokens}
598
+ onChange={(v) =>
599
+ patchProfileField(key, "maxOutputTokens", v)
600
+ }
601
+ min={100}
602
+ max={32000}
603
+ step={100}
604
+ />
605
+ </div>
606
+ <div>
607
+ <Label>Max Steps</Label>
608
+ <NumberInput
609
+ value={draft.responseProfiles[key].maxSteps}
610
+ onChange={(v) => patchProfileField(key, "maxSteps", v)}
611
+ min={1}
612
+ max={20}
613
+ />
614
+ </div>
615
+ </div>
616
+ ))}
617
+ </div>
618
+ </Section>
619
+
620
+ {/* ── Prompt Groups ─────────────────────────────────── */}
621
+ {Object.entries(draft.promptGroups).map(([groupKey, group]) => (
622
+ <PromptGroupSection
623
+ key={groupKey}
624
+ groupKey={groupKey}
625
+ group={group}
626
+ onOptionChange={(optKey, value) =>
627
+ patchGroupOption(groupKey, optKey, value)
628
+ }
629
+ onDefaultChange={(optKey) => patchGroupDefault(groupKey, optKey)}
630
+ onAddOption={(optKey, prompt) =>
631
+ addGroupOption(groupKey, optKey, prompt)
632
+ }
633
+ onRemoveOption={(optKey) => removeGroupOption(groupKey, optKey)}
634
+ onRemoveGroup={() => removeGroup(groupKey)}
635
+ onMetaChange={(field, value) =>
636
+ patchGroupMeta(groupKey, field, value)
637
+ }
638
+ />
639
+ ))}
640
+
641
+ {/* ── Add group form ─────────────────────────────── */}
642
+ {showNewGroupForm ? (
643
+ <div className="border border-cyan-600/30 rounded-lg p-4 space-y-4 bg-slate-900/60">
644
+ <p className="text-sm font-medium text-cyan-300">
645
+ New setting group
646
+ </p>
647
+ <div className="grid grid-cols-3 gap-3">
648
+ <div>
649
+ <Label>Key (internal ID)</Label>
650
+ <input
651
+ type="text"
652
+ value={newGroupKey}
653
+ onChange={(e) =>
654
+ setNewGroupKey(
655
+ e.target.value.toLowerCase().replace(/\s+/g, "-"),
656
+ )
657
+ }
658
+ placeholder="e.g. tone"
659
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
660
+ />
661
+ {newGroupKey && newGroupKey in draft.promptGroups && (
662
+ <p className="text-xs text-red-400 mt-1">
663
+ Key already exists.
664
+ </p>
665
+ )}
666
+ </div>
667
+ <div>
668
+ <Label>Label (shown in bottom bar)</Label>
669
+ <input
670
+ type="text"
671
+ value={newGroupLabel}
672
+ onChange={(e) => setNewGroupLabel(e.target.value)}
673
+ placeholder="e.g. Response Tone"
674
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
675
+ />
676
+ </div>
677
+ <div>
678
+ <Label>Description (optional)</Label>
679
+ <input
680
+ type="text"
681
+ value={newGroupDescription}
682
+ onChange={(e) => setNewGroupDescription(e.target.value)}
683
+ placeholder="Shown in the settings panel"
684
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
685
+ />
686
+ </div>
687
+ </div>
688
+
689
+ <div className="space-y-2">
690
+ <Label>Options (first will be the default)</Label>
691
+ {newGroupOptions.map((opt, i) => (
692
+ <div key={i} className="flex gap-2 items-start">
693
+ <input
694
+ type="text"
695
+ value={opt.key}
696
+ onChange={(e) => {
697
+ const upd = [...newGroupOptions];
698
+ upd[i] = {
699
+ ...upd[i],
700
+ key: e.target.value
701
+ .toLowerCase()
702
+ .replace(/\s+/g, "-"),
703
+ };
704
+ setNewGroupOptions(upd);
705
+ }}
706
+ placeholder="key"
707
+ className="w-28 shrink-0 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
708
+ />
709
+ <input
710
+ type="text"
711
+ value={opt.prompt}
712
+ onChange={(e) => {
713
+ const upd = [...newGroupOptions];
714
+ upd[i] = { ...upd[i], prompt: e.target.value };
715
+ setNewGroupOptions(upd);
716
+ }}
717
+ placeholder="Prompt text appended to message when selected (can be empty)"
718
+ className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
719
+ />
720
+ {newGroupOptions.length > 1 && (
721
+ <button
722
+ type="button"
723
+ onClick={() =>
724
+ setNewGroupOptions((prev) =>
725
+ prev.filter((_, j) => j !== i),
726
+ )
727
+ }
728
+ className="mt-1 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
729
+ >
730
+ <Trash2 className="w-3.5 h-3.5" />
731
+ </button>
732
+ )}
733
+ </div>
734
+ ))}
735
+ <button
736
+ type="button"
737
+ onClick={() =>
738
+ setNewGroupOptions((prev) => [
739
+ ...prev,
740
+ { key: "", prompt: "" },
741
+ ])
742
+ }
743
+ className="flex items-center gap-1 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
744
+ >
745
+ <Plus className="w-3.5 h-3.5" /> Add option row
746
+ </button>
747
+ </div>
748
+
749
+ <div className="flex gap-2 pt-1">
750
+ <button
751
+ type="button"
752
+ onClick={addNewGroup}
753
+ disabled={
754
+ !newGroupKey.trim() ||
755
+ newGroupKey in draft.promptGroups ||
756
+ newGroupOptions.every((o) => !o.key.trim())
757
+ }
758
+ className="px-4 py-1.5 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
759
+ >
760
+ Create group
761
+ </button>
762
+ <button
763
+ type="button"
764
+ onClick={resetNewGroupForm}
765
+ className="px-4 py-1.5 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
766
+ >
767
+ Cancel
768
+ </button>
769
+ </div>
770
+ </div>
771
+ ) : (
772
+ <button
773
+ type="button"
774
+ onClick={() => setShowNewGroupForm(true)}
775
+ className="flex items-center gap-2 w-full justify-center py-2.5 border border-dashed border-slate-700 hover:border-cyan-600/50 rounded-lg text-sm text-slate-500 hover:text-cyan-400 transition-colors"
776
+ >
777
+ <Plus className="w-4 h-4" /> Add setting group
778
+ </button>
779
+ )}
780
+
781
+ {/* ── Viz Guide ─────────────────────────────────────── */}
782
+ <Section
783
+ title="Viz Guide (returned by getVizGuide tool)"
784
+ defaultOpen={false}
785
+ >
786
+ <p className="text-xs text-slate-500 -mt-1">
787
+ The full VizCraft spec reference the AI receives when it calls{" "}
788
+ <code className="text-cyan-400">getVizGuide()</code>.
789
+ </p>
790
+ <Textarea
791
+ value={draft.vizGuide}
792
+ onChange={(v) => patchTop("vizGuide", v)}
793
+ rows={16}
794
+ mono
795
+ />
796
+ </Section>
797
+ </div>
798
+
799
+ {/* Footer */}
800
+ <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-700 shrink-0">
801
+ <button
802
+ type="button"
803
+ onClick={closeSettings}
804
+ className="px-4 py-2 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
805
+ >
806
+ Cancel
807
+ </button>
808
+ <button
809
+ type="button"
810
+ onClick={handleSave}
811
+ disabled={saving}
812
+ className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-60 text-white transition-colors"
813
+ >
814
+ {saving ? (
815
+ <Loader2 className="w-4 h-4 animate-spin" />
816
+ ) : saved ? (
817
+ <Check className="w-4 h-4" />
818
+ ) : (
819
+ <Save className="w-4 h-4" />
820
+ )}
821
+ {saved ? "Saved!" : "Save changes"}
822
+ </button>
823
+ </div>
824
+ </div>
825
+ </div>
826
+ );
827
+ }