create-interview-cockpit 0.3.0 → 0.5.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.
Files changed (28) hide show
  1. package/README.md +23 -0
  2. package/package.json +1 -1
  3. package/template/client/package-lock.json +42 -0
  4. package/template/client/package.json +5 -0
  5. package/template/client/src/App.tsx +45 -12
  6. package/template/client/src/api.ts +174 -0
  7. package/template/client/src/components/AiSettingsModal.tsx +1041 -0
  8. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  9. package/template/client/src/components/ChatMessage.tsx +110 -27
  10. package/template/client/src/components/ChatView.tsx +239 -137
  11. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  12. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  13. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  14. package/template/client/src/components/DocRefModal.tsx +502 -0
  15. package/template/client/src/components/FileAttachments.tsx +109 -9
  16. package/template/client/src/components/FilePickerModal.tsx +181 -0
  17. package/template/client/src/components/FileViewerModal.tsx +406 -28
  18. package/template/client/src/components/MarkdownRenderer.tsx +210 -2
  19. package/template/client/src/components/Sidebar.tsx +213 -125
  20. package/template/client/src/components/TextAnnotator.tsx +8 -15
  21. package/template/client/src/components/VizCraftEmbed.tsx +645 -0
  22. package/template/client/src/store.ts +275 -0
  23. package/template/client/src/types.ts +9 -0
  24. package/template/cockpit.json +1 -1
  25. package/template/data/ai-settings.json +49 -0
  26. package/template/server/src/google-drive.ts +109 -1
  27. package/template/server/src/index.ts +1187 -76
  28. package/template/server/src/storage.ts +359 -2
@@ -0,0 +1,1041 @@
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
+ brief: { maxOutputTokens: 10000, maxSteps: 5 },
25
+ },
26
+ vizGuide: "",
27
+ alwaysSendPrefsDefault: false,
28
+ promptGroups: {
29
+ length: {
30
+ label: "Response Length",
31
+ description:
32
+ "Appended to the user message when the selected length changes.",
33
+ default: "normal",
34
+ options: {
35
+ concise:
36
+ "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.",
37
+ moderate:
38
+ "Keep the response moderately detailed. Aim for roughly 550 characters of text when possible.",
39
+ normal:
40
+ "Use a fuller answer with enough context to explain the idea clearly.",
41
+ },
42
+ },
43
+ style: {
44
+ label: "Response Style",
45
+ description:
46
+ "Appended to the user message when the selected style changes.",
47
+ default: "prose",
48
+ options: {
49
+ prose:
50
+ "Use natural prose with short paragraphs. Avoid bullet lists and numbered lists unless I explicitly ask for them.",
51
+ bullets: "Use bullet points and short lists as the main format.",
52
+ structured:
53
+ "Use structured sections with headings and numbered steps when helpful.",
54
+ },
55
+ },
56
+ audience: {
57
+ label: "Response Audience",
58
+ description:
59
+ "Appended to the user message when the selected audience changes.",
60
+ default: "normal",
61
+ options: {
62
+ normal: "",
63
+ beginner:
64
+ "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.",
65
+ },
66
+ },
67
+ },
68
+ };
69
+
70
+ // ── Helpers ───────────────────────────────────────────────────────────────────
71
+
72
+ function Section({
73
+ title,
74
+ children,
75
+ defaultOpen = true,
76
+ trailingAction,
77
+ }: {
78
+ title: string;
79
+ children: React.ReactNode;
80
+ defaultOpen?: boolean;
81
+ trailingAction?: React.ReactNode;
82
+ }) {
83
+ const [open, setOpen] = useState(defaultOpen);
84
+ return (
85
+ <div className="border border-slate-700 rounded-lg overflow-hidden">
86
+ <button
87
+ type="button"
88
+ onClick={() => setOpen((v) => !v)}
89
+ className="w-full flex items-center justify-between px-4 py-3 bg-slate-800/60 hover:bg-slate-800 text-left transition-colors"
90
+ >
91
+ <span className="text-sm font-medium text-slate-200">{title}</span>
92
+ <div className="flex items-center gap-2">
93
+ {trailingAction && (
94
+ <span
95
+ onClick={(e) => e.stopPropagation()}
96
+ className="flex items-center"
97
+ >
98
+ {trailingAction}
99
+ </span>
100
+ )}
101
+ {open ? (
102
+ <ChevronDown className="w-4 h-4 text-slate-400 shrink-0" />
103
+ ) : (
104
+ <ChevronRight className="w-4 h-4 text-slate-400 shrink-0" />
105
+ )}
106
+ </div>
107
+ </button>
108
+ {open && <div className="p-4 space-y-4 bg-slate-900/40">{children}</div>}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ function Label({ children }: { children: React.ReactNode }) {
114
+ return (
115
+ <label className="block text-xs font-medium text-slate-400 mb-1 uppercase tracking-wide">
116
+ {children}
117
+ </label>
118
+ );
119
+ }
120
+
121
+ function Textarea({
122
+ value,
123
+ onChange,
124
+ rows = 4,
125
+ mono = false,
126
+ }: {
127
+ value: string;
128
+ onChange: (v: string) => void;
129
+ rows?: number;
130
+ mono?: boolean;
131
+ }) {
132
+ return (
133
+ <textarea
134
+ rows={rows}
135
+ value={value}
136
+ onChange={(e) => onChange(e.target.value)}
137
+ 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" : ""}`}
138
+ />
139
+ );
140
+ }
141
+
142
+ function NumberInput({
143
+ value,
144
+ onChange,
145
+ min,
146
+ max,
147
+ step = 1,
148
+ }: {
149
+ value: number;
150
+ onChange: (v: number) => void;
151
+ min?: number;
152
+ max?: number;
153
+ step?: number;
154
+ }) {
155
+ return (
156
+ <input
157
+ type="number"
158
+ value={value}
159
+ min={min}
160
+ max={max}
161
+ step={step}
162
+ onChange={(e) => onChange(Number(e.target.value))}
163
+ 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"
164
+ />
165
+ );
166
+ }
167
+
168
+ // ── Generic prompt-group section ───────────────────────────────────────────
169
+
170
+ function PromptGroupSection({
171
+ groupKey,
172
+ group,
173
+ onOptionChange,
174
+ onDefaultChange,
175
+ onAddOption,
176
+ onRemoveOption,
177
+ onRemoveGroup,
178
+ onMetaChange,
179
+ }: {
180
+ groupKey: string;
181
+ group: PromptGroup;
182
+ onOptionChange: (optKey: string, value: string) => void;
183
+ onDefaultChange: (optKey: string) => void;
184
+ onAddOption: (optKey: string, prompt: string) => void;
185
+ onRemoveOption: (optKey: string) => void;
186
+ onRemoveGroup: () => void;
187
+ onMetaChange: (field: "label" | "description", value: string) => void;
188
+ }) {
189
+ const [newOptKey, setNewOptKey] = useState("");
190
+ const [newOptPrompt, setNewOptPrompt] = useState("");
191
+ const [showAddOpt, setShowAddOpt] = useState(false);
192
+ const optionCount = Object.keys(group.options).length;
193
+
194
+ const handleAddOption = () => {
195
+ const key = newOptKey.trim();
196
+ if (!key || key in group.options) return;
197
+ onAddOption(key, newOptPrompt);
198
+ setNewOptKey("");
199
+ setNewOptPrompt("");
200
+ setShowAddOpt(false);
201
+ };
202
+
203
+ return (
204
+ <Section
205
+ title={group.label || groupKey}
206
+ trailingAction={
207
+ <button
208
+ type="button"
209
+ onClick={onRemoveGroup}
210
+ title="Delete this group"
211
+ className="p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
212
+ >
213
+ <Trash2 className="w-3.5 h-3.5" />
214
+ </button>
215
+ }
216
+ >
217
+ {/* Meta fields */}
218
+ <div className="grid grid-cols-2 gap-3">
219
+ <div>
220
+ <Label>Label (shown in bottom bar)</Label>
221
+ <input
222
+ type="text"
223
+ value={group.label}
224
+ onChange={(e) => onMetaChange("label", e.target.value)}
225
+ 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"
226
+ />
227
+ </div>
228
+ <div>
229
+ <Label>Description</Label>
230
+ <input
231
+ type="text"
232
+ value={group.description ?? ""}
233
+ onChange={(e) => onMetaChange("description", e.target.value)}
234
+ 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"
235
+ />
236
+ </div>
237
+ </div>
238
+
239
+ {/* Options */}
240
+ {Object.entries(group.options).map(([optKey, optValue]) => (
241
+ <div key={optKey} className="flex gap-3 items-start">
242
+ <button
243
+ type="button"
244
+ title="Set as default"
245
+ onClick={() => onDefaultChange(optKey)}
246
+ className={`mt-6 shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
247
+ group.default === optKey
248
+ ? "border-cyan-400 bg-cyan-400"
249
+ : "border-slate-600 hover:border-slate-400"
250
+ }`}
251
+ >
252
+ {group.default === optKey && (
253
+ <span className="w-1.5 h-1.5 rounded-full bg-slate-900" />
254
+ )}
255
+ </button>
256
+ <div className="flex-1">
257
+ <Label>
258
+ {optKey}
259
+ {group.default === optKey && (
260
+ <span className="ml-2 text-cyan-400 normal-case font-normal">
261
+ (default)
262
+ </span>
263
+ )}
264
+ </Label>
265
+ <Textarea
266
+ value={optValue}
267
+ onChange={(v) => onOptionChange(optKey, v)}
268
+ rows={optValue.length > 120 ? 3 : 2}
269
+ />
270
+ </div>
271
+ {optionCount > 1 && (
272
+ <button
273
+ type="button"
274
+ title="Remove option"
275
+ onClick={() => onRemoveOption(optKey)}
276
+ className="mt-6 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
277
+ >
278
+ <Trash2 className="w-3.5 h-3.5" />
279
+ </button>
280
+ )}
281
+ </div>
282
+ ))}
283
+
284
+ {/* Add option */}
285
+ {showAddOpt ? (
286
+ <div className="border border-slate-700 rounded-lg p-3 space-y-2 bg-slate-800/40">
287
+ <p className="text-xs font-medium text-slate-400">New option</p>
288
+ <div className="flex gap-2">
289
+ <div className="w-36">
290
+ <Label>Key (e.g. "verbose")</Label>
291
+ <input
292
+ type="text"
293
+ value={newOptKey}
294
+ onChange={(e) =>
295
+ setNewOptKey(
296
+ e.target.value.toLowerCase().replace(/\s+/g, "-"),
297
+ )
298
+ }
299
+ placeholder="key"
300
+ 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"
301
+ />
302
+ </div>
303
+ <div className="flex-1">
304
+ <Label>Prompt text</Label>
305
+ <input
306
+ type="text"
307
+ value={newOptPrompt}
308
+ onChange={(e) => setNewOptPrompt(e.target.value)}
309
+ placeholder="Appended to user message when selected"
310
+ 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"
311
+ />
312
+ </div>
313
+ </div>
314
+ <div className="flex gap-2">
315
+ <button
316
+ type="button"
317
+ onClick={handleAddOption}
318
+ disabled={!newOptKey.trim() || newOptKey in group.options}
319
+ className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
320
+ >
321
+ Add
322
+ </button>
323
+ <button
324
+ type="button"
325
+ onClick={() => {
326
+ setShowAddOpt(false);
327
+ setNewOptKey("");
328
+ setNewOptPrompt("");
329
+ }}
330
+ className="px-3 py-1 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
331
+ >
332
+ Cancel
333
+ </button>
334
+ </div>
335
+ </div>
336
+ ) : (
337
+ <button
338
+ type="button"
339
+ onClick={() => setShowAddOpt(true)}
340
+ className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
341
+ >
342
+ <Plus className="w-3.5 h-3.5" /> Add option
343
+ </button>
344
+ )}
345
+ </Section>
346
+ );
347
+ }
348
+
349
+ // ── Main modal ───────────────────────────────────────────────────────────────
350
+
351
+ export default function AiSettingsModal() {
352
+ const { aiSettings, saveAiSettings, closeSettings } = useStore();
353
+ const [draft, setDraft] = useState<AiSettings>(() =>
354
+ JSON.parse(JSON.stringify(aiSettings)),
355
+ );
356
+ const [saving, setSaving] = useState(false);
357
+ const [saved, setSaved] = useState(false);
358
+ const overlayRef = useRef<HTMLDivElement>(null);
359
+
360
+ // ── New profile form state ────────────────────────────────────
361
+ const [showNewProfileForm, setShowNewProfileForm] = useState(false);
362
+ const [newProfileKey, setNewProfileKey] = useState("");
363
+
364
+ function resetNewProfileForm() {
365
+ setShowNewProfileForm(false);
366
+ setNewProfileKey("");
367
+ }
368
+
369
+ // ── New group form state ─────────────────────
370
+ const [showNewGroupForm, setShowNewGroupForm] = useState(false);
371
+ const [newGroupKey, setNewGroupKey] = useState("");
372
+ const [newGroupLabel, setNewGroupLabel] = useState("");
373
+ const [newGroupDescription, setNewGroupDescription] = useState("");
374
+ const [newGroupOptions, setNewGroupOptions] = useState([
375
+ { key: "", prompt: "" },
376
+ ]);
377
+
378
+ function resetNewGroupForm() {
379
+ setShowNewGroupForm(false);
380
+ setNewGroupKey("");
381
+ setNewGroupLabel("");
382
+ setNewGroupDescription("");
383
+ setNewGroupOptions([{ key: "", prompt: "" }]);
384
+ }
385
+ // ───────────────────────────────────────────
386
+
387
+ // Sync draft if settings change externally (e.g. first fetch)
388
+ useEffect(() => {
389
+ setDraft(JSON.parse(JSON.stringify(aiSettings)));
390
+ }, [aiSettings]);
391
+
392
+ // Close on Escape
393
+ useEffect(() => {
394
+ const handler = (e: KeyboardEvent) => {
395
+ if (e.key === "Escape") closeSettings();
396
+ };
397
+ window.addEventListener("keydown", handler);
398
+ return () => window.removeEventListener("keydown", handler);
399
+ }, [closeSettings]);
400
+
401
+ // ── Patch helpers ─────────────────────────────────────────────
402
+ function patchTop<K extends keyof AiSettings>(key: K, value: AiSettings[K]) {
403
+ setDraft((d) => ({ ...d, [key]: value }));
404
+ }
405
+
406
+ function patchProfileField(
407
+ profile: string,
408
+ field: "maxOutputTokens" | "maxSteps",
409
+ value: number,
410
+ ) {
411
+ setDraft((d) => ({
412
+ ...d,
413
+ responseProfiles: {
414
+ ...d.responseProfiles,
415
+ [profile]: { ...d.responseProfiles[profile], [field]: value },
416
+ },
417
+ }));
418
+ }
419
+
420
+ function addProfile(key: string) {
421
+ const k = key.trim().toLowerCase().replace(/\s+/g, "-");
422
+ if (!k || k in draft.responseProfiles) return;
423
+ setDraft((d) => ({
424
+ ...d,
425
+ responseProfiles: {
426
+ ...d.responseProfiles,
427
+ [k]: { maxOutputTokens: 2000, maxSteps: 5 },
428
+ },
429
+ }));
430
+ }
431
+
432
+ function removeProfile(key: string) {
433
+ setDraft((d) => {
434
+ const next = { ...d.responseProfiles };
435
+ delete next[key];
436
+ return { ...d, responseProfiles: next };
437
+ });
438
+ }
439
+
440
+ function patchGroupOption(groupKey: string, optKey: string, value: string) {
441
+ setDraft((d) => ({
442
+ ...d,
443
+ promptGroups: {
444
+ ...d.promptGroups,
445
+ [groupKey]: {
446
+ ...d.promptGroups[groupKey],
447
+ options: { ...d.promptGroups[groupKey].options, [optKey]: value },
448
+ },
449
+ },
450
+ }));
451
+ }
452
+
453
+ function patchGroupDefault(groupKey: string, optKey: string) {
454
+ setDraft((d) => ({
455
+ ...d,
456
+ promptGroups: {
457
+ ...d.promptGroups,
458
+ [groupKey]: { ...d.promptGroups[groupKey], default: optKey },
459
+ },
460
+ }));
461
+ }
462
+
463
+ function patchGroupMeta(
464
+ groupKey: string,
465
+ field: "label" | "description",
466
+ value: string,
467
+ ) {
468
+ setDraft((d) => ({
469
+ ...d,
470
+ promptGroups: {
471
+ ...d.promptGroups,
472
+ [groupKey]: { ...d.promptGroups[groupKey], [field]: value },
473
+ },
474
+ }));
475
+ }
476
+
477
+ function addGroupOption(groupKey: string, optKey: string, prompt: string) {
478
+ setDraft((d) => {
479
+ const g = d.promptGroups[groupKey];
480
+ return {
481
+ ...d,
482
+ promptGroups: {
483
+ ...d.promptGroups,
484
+ [groupKey]: {
485
+ ...g,
486
+ options: { ...g.options, [optKey]: prompt },
487
+ },
488
+ },
489
+ };
490
+ });
491
+ }
492
+
493
+ function removeGroupOption(groupKey: string, optKey: string) {
494
+ setDraft((d) => {
495
+ const g = d.promptGroups[groupKey];
496
+ const newOptions = { ...g.options };
497
+ delete newOptions[optKey];
498
+ const firstKey = Object.keys(newOptions)[0] ?? "";
499
+ return {
500
+ ...d,
501
+ promptGroups: {
502
+ ...d.promptGroups,
503
+ [groupKey]: {
504
+ ...g,
505
+ options: newOptions,
506
+ default: g.default === optKey ? firstKey : g.default,
507
+ },
508
+ },
509
+ };
510
+ });
511
+ }
512
+
513
+ function removeGroup(groupKey: string) {
514
+ if (
515
+ !window.confirm(
516
+ `Delete the “${draft.promptGroups[groupKey]?.label || groupKey}” group? This will also remove it from the bottom bar.`,
517
+ )
518
+ )
519
+ return;
520
+ setDraft((d) => {
521
+ const newGroups = { ...d.promptGroups };
522
+ delete newGroups[groupKey];
523
+ return { ...d, promptGroups: newGroups };
524
+ });
525
+ }
526
+
527
+ function addNewGroup() {
528
+ const key = newGroupKey.trim().toLowerCase().replace(/\s+/g, "-");
529
+ if (!key || key in draft.promptGroups) return;
530
+ if (newGroupOptions.some((o) => !o.key.trim())) return;
531
+ const options = Object.fromEntries(
532
+ newGroupOptions.map((o) => [o.key.trim(), o.prompt]),
533
+ );
534
+ const firstKey = Object.keys(options)[0] ?? "";
535
+ setDraft((d) => ({
536
+ ...d,
537
+ promptGroups: {
538
+ ...d.promptGroups,
539
+ [key]: {
540
+ label: newGroupLabel.trim() || key,
541
+ description: newGroupDescription.trim() || undefined,
542
+ default: firstKey,
543
+ options,
544
+ },
545
+ },
546
+ }));
547
+ resetNewGroupForm();
548
+ }
549
+
550
+ // ── Actions ───────────────────────────────────────────────────
551
+ async function handleSave() {
552
+ if (saving) return;
553
+ setSaving(true);
554
+ try {
555
+ await saveAiSettings(draft);
556
+ setSaved(true);
557
+ setTimeout(() => setSaved(false), 2000);
558
+ } finally {
559
+ setSaving(false);
560
+ }
561
+ }
562
+
563
+ function handleReset() {
564
+ if (!window.confirm("Reset all settings to the committed baseline?"))
565
+ return;
566
+ setDraft(JSON.parse(JSON.stringify(BASELINE)));
567
+ }
568
+
569
+ // ── Render ────────────────────────────────────────────────────
570
+ const profileKeys = Object.keys(draft.responseProfiles);
571
+
572
+ return (
573
+ <div
574
+ ref={overlayRef}
575
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
576
+ onMouseDown={(e) => {
577
+ if (e.target === overlayRef.current) closeSettings();
578
+ }}
579
+ >
580
+ <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">
581
+ {/* Header */}
582
+ <div className="flex items-center justify-between px-6 py-4 border-b border-slate-700 shrink-0">
583
+ <h2 className="text-base font-semibold text-slate-100">
584
+ AI Settings
585
+ </h2>
586
+ <div className="flex items-center gap-2">
587
+ <button
588
+ type="button"
589
+ onClick={handleReset}
590
+ title="Reset to baseline defaults"
591
+ 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"
592
+ >
593
+ <RotateCcw className="w-3.5 h-3.5" />
594
+ Reset to defaults
595
+ </button>
596
+ <button
597
+ type="button"
598
+ onClick={closeSettings}
599
+ className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
600
+ >
601
+ <X className="w-4 h-4" />
602
+ </button>
603
+ </div>
604
+ </div>
605
+
606
+ {/* Scrollable body */}
607
+ <div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
608
+ {/* ── System Prompt ─────────────────────────────────── */}
609
+ <Section title="System Prompt">
610
+ <Textarea
611
+ value={draft.systemPrompt}
612
+ onChange={(v) => patchTop("systemPrompt", v)}
613
+ rows={8}
614
+ />
615
+ </Section>
616
+
617
+ {/* ── Response Profiles ─────────────────────────────── */}
618
+ <Section title="Response Profiles (token limits per length setting)">
619
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
620
+ {profileKeys.map((key) => (
621
+ <div key={key} className="space-y-3">
622
+ <div className="flex items-center justify-between">
623
+ <p className="text-xs font-semibold text-cyan-400 capitalize">
624
+ {key}
625
+ </p>
626
+ {profileKeys.length > 1 && (
627
+ <button
628
+ type="button"
629
+ title={`Remove "${key}" profile`}
630
+ onClick={() => removeProfile(key)}
631
+ className="p-0.5 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
632
+ >
633
+ <Trash2 className="w-3 h-3" />
634
+ </button>
635
+ )}
636
+ </div>
637
+ <div>
638
+ <Label>Max Output Tokens</Label>
639
+ <NumberInput
640
+ value={draft.responseProfiles[key].maxOutputTokens}
641
+ onChange={(v) =>
642
+ patchProfileField(key, "maxOutputTokens", v)
643
+ }
644
+ min={100}
645
+ max={32000}
646
+ step={100}
647
+ />
648
+ </div>
649
+ <div>
650
+ <Label>Max Steps</Label>
651
+ <NumberInput
652
+ value={draft.responseProfiles[key].maxSteps}
653
+ onChange={(v) => patchProfileField(key, "maxSteps", v)}
654
+ min={1}
655
+ max={20}
656
+ />
657
+ </div>
658
+ </div>
659
+ ))}
660
+ </div>
661
+
662
+ {/* Add profile */}
663
+ {showNewProfileForm ? (
664
+ <div className="mt-4 border border-slate-700 rounded-lg p-3 space-y-2 bg-slate-800/40">
665
+ <p className="text-xs font-medium text-slate-400">
666
+ New profile key
667
+ </p>
668
+ <div className="flex gap-2">
669
+ <input
670
+ type="text"
671
+ value={newProfileKey}
672
+ onChange={(e) =>
673
+ setNewProfileKey(
674
+ e.target.value.toLowerCase().replace(/\s+/g, "-"),
675
+ )
676
+ }
677
+ placeholder="e.g. brief"
678
+ 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"
679
+ />
680
+ <button
681
+ type="button"
682
+ onClick={() => {
683
+ addProfile(newProfileKey);
684
+ resetNewProfileForm();
685
+ }}
686
+ disabled={
687
+ !newProfileKey.trim() ||
688
+ newProfileKey.trim() in draft.responseProfiles
689
+ }
690
+ className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
691
+ >
692
+ Add
693
+ </button>
694
+ <button
695
+ type="button"
696
+ onClick={resetNewProfileForm}
697
+ className="px-3 py-1 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
698
+ >
699
+ Cancel
700
+ </button>
701
+ </div>
702
+ {newProfileKey.trim() in draft.responseProfiles && (
703
+ <p className="text-xs text-red-400">
704
+ A profile with that key already exists.
705
+ </p>
706
+ )}
707
+ </div>
708
+ ) : (
709
+ <button
710
+ type="button"
711
+ onClick={() => setShowNewProfileForm(true)}
712
+ className="mt-3 flex items-center gap-1.5 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
713
+ >
714
+ <Plus className="w-3.5 h-3.5" /> Add profile
715
+ </button>
716
+ )}
717
+ </Section>
718
+
719
+ {/* ── Prompt Groups ─────────────────────────────────── */}
720
+ {Object.entries(draft.promptGroups).map(([groupKey, group]) => (
721
+ <PromptGroupSection
722
+ key={groupKey}
723
+ groupKey={groupKey}
724
+ group={group}
725
+ onOptionChange={(optKey, value) =>
726
+ patchGroupOption(groupKey, optKey, value)
727
+ }
728
+ onDefaultChange={(optKey) => patchGroupDefault(groupKey, optKey)}
729
+ onAddOption={(optKey, prompt) =>
730
+ addGroupOption(groupKey, optKey, prompt)
731
+ }
732
+ onRemoveOption={(optKey) => removeGroupOption(groupKey, optKey)}
733
+ onRemoveGroup={() => removeGroup(groupKey)}
734
+ onMetaChange={(field, value) =>
735
+ patchGroupMeta(groupKey, field, value)
736
+ }
737
+ />
738
+ ))}
739
+
740
+ {/* ── Preferences behaviour ─────────────────────────── */}
741
+ <Section title="Preference Sending Behaviour">
742
+ <div className="flex items-center justify-between">
743
+ <div>
744
+ <p className="text-sm text-slate-200">
745
+ Always send preferences
746
+ </p>
747
+ <p className="text-xs text-slate-500 mt-0.5">
748
+ When on, preference prompt texts are appended to every
749
+ message. When off, they are only sent when you change a
750
+ setting (saves tokens).
751
+ </p>
752
+ </div>
753
+ <button
754
+ type="button"
755
+ onClick={() =>
756
+ patchTop(
757
+ "alwaysSendPrefsDefault",
758
+ !draft.alwaysSendPrefsDefault,
759
+ )
760
+ }
761
+ className={`relative shrink-0 w-10 h-5 rounded-full transition-colors ${
762
+ draft.alwaysSendPrefsDefault ? "bg-amber-500" : "bg-slate-700"
763
+ }`}
764
+ >
765
+ <span
766
+ className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
767
+ draft.alwaysSendPrefsDefault ? "translate-x-5" : ""
768
+ }`}
769
+ />
770
+ </button>
771
+ </div>
772
+ </Section>
773
+
774
+ {/* ── Model / Thinking ───────────────────────────────── */}
775
+ <Section title="Model & Thinking" defaultOpen={false}>
776
+ {/* Current model info (read-only) */}
777
+ <div className="flex gap-3 mb-4">
778
+ <div className="flex-1">
779
+ <Label>Provider</Label>
780
+ <div className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-400 font-mono">
781
+ {draft.provider || "openai"}
782
+ </div>
783
+ </div>
784
+ <div className="flex-1">
785
+ <Label>Model</Label>
786
+ <div className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-400 font-mono truncate">
787
+ {draft.model || "(default)"}
788
+ </div>
789
+ </div>
790
+ </div>
791
+
792
+ {/* Thinking budget — only useful for Google/Gemini */}
793
+ {["google", "gemini"].includes(draft.provider ?? "") ? (
794
+ <div>
795
+ <Label>Thinking Budget</Label>
796
+ <p className="text-xs text-slate-500 mb-2">
797
+ Number of tokens Gemini can use for internal reasoning before
798
+ responding. 0 = disabled. Shows a collapsible "Thinking…"
799
+ block in the chat. Recommended: 8000 for medium, 0 to save
800
+ tokens.
801
+ </p>
802
+ <div className="flex items-center gap-2 flex-wrap">
803
+ {[
804
+ { label: "Off", value: 0 },
805
+ { label: "Low", value: 1024 },
806
+ { label: "Medium", value: 8192 },
807
+ { label: "High", value: 24576 },
808
+ ].map((preset) => (
809
+ <button
810
+ key={preset.label}
811
+ type="button"
812
+ onClick={() => patchTop("thinkingBudget", preset.value)}
813
+ className={`px-3 py-1 text-xs rounded-md border transition-colors ${
814
+ (draft.thinkingBudget ?? 0) === preset.value
815
+ ? "bg-cyan-600/30 text-cyan-300 border-cyan-600/50"
816
+ : "text-slate-500 hover:text-slate-300 border-slate-700 hover:border-slate-500"
817
+ }`}
818
+ >
819
+ {preset.label}
820
+ {preset.value > 0 && (
821
+ <span className="ml-1 opacity-60">
822
+ ({preset.value.toLocaleString()})
823
+ </span>
824
+ )}
825
+ </button>
826
+ ))}
827
+ <div className="flex items-center gap-1.5">
828
+ <span className="text-xs text-slate-500">Custom:</span>
829
+ <input
830
+ type="number"
831
+ value={draft.thinkingBudget ?? 0}
832
+ min={0}
833
+ max={32768}
834
+ step={256}
835
+ onChange={(e) =>
836
+ patchTop("thinkingBudget", Number(e.target.value))
837
+ }
838
+ className="w-24 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"
839
+ />
840
+ </div>
841
+ </div>
842
+ </div>
843
+ ) : (
844
+ <p className="text-xs text-slate-500">
845
+ Thinking / reasoning display is only available for Google /
846
+ Gemini models. Switch{" "}
847
+ <code className="bg-slate-800 px-1 rounded">AI_PROVIDER</code>{" "}
848
+ to <code className="bg-slate-800 px-1 rounded">google</code> in
849
+ your <code className="bg-slate-800 px-1 rounded">.env</code> to
850
+ enable it.
851
+ </p>
852
+ )}
853
+ </Section>
854
+
855
+ {/* ── Add group form ─────────────────────────────── */}
856
+ {showNewGroupForm ? (
857
+ <div className="border border-cyan-600/30 rounded-lg p-4 space-y-4 bg-slate-900/60">
858
+ <p className="text-sm font-medium text-cyan-300">
859
+ New setting group
860
+ </p>
861
+ <div className="grid grid-cols-3 gap-3">
862
+ <div>
863
+ <Label>Key (internal ID)</Label>
864
+ <input
865
+ type="text"
866
+ value={newGroupKey}
867
+ onChange={(e) =>
868
+ setNewGroupKey(
869
+ e.target.value.toLowerCase().replace(/\s+/g, "-"),
870
+ )
871
+ }
872
+ placeholder="e.g. tone"
873
+ 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"
874
+ />
875
+ {newGroupKey && newGroupKey in draft.promptGroups && (
876
+ <p className="text-xs text-red-400 mt-1">
877
+ Key already exists.
878
+ </p>
879
+ )}
880
+ </div>
881
+ <div>
882
+ <Label>Label (shown in bottom bar)</Label>
883
+ <input
884
+ type="text"
885
+ value={newGroupLabel}
886
+ onChange={(e) => setNewGroupLabel(e.target.value)}
887
+ placeholder="e.g. Response Tone"
888
+ 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"
889
+ />
890
+ </div>
891
+ <div>
892
+ <Label>Description (optional)</Label>
893
+ <input
894
+ type="text"
895
+ value={newGroupDescription}
896
+ onChange={(e) => setNewGroupDescription(e.target.value)}
897
+ placeholder="Shown in the settings panel"
898
+ 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"
899
+ />
900
+ </div>
901
+ </div>
902
+
903
+ <div className="space-y-2">
904
+ <Label>Options (first will be the default)</Label>
905
+ {newGroupOptions.map((opt, i) => (
906
+ <div key={i} className="flex gap-2 items-start">
907
+ <input
908
+ type="text"
909
+ value={opt.key}
910
+ onChange={(e) => {
911
+ const upd = [...newGroupOptions];
912
+ upd[i] = {
913
+ ...upd[i],
914
+ key: e.target.value
915
+ .toLowerCase()
916
+ .replace(/\s+/g, "-"),
917
+ };
918
+ setNewGroupOptions(upd);
919
+ }}
920
+ placeholder="key"
921
+ 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"
922
+ />
923
+ <input
924
+ type="text"
925
+ value={opt.prompt}
926
+ onChange={(e) => {
927
+ const upd = [...newGroupOptions];
928
+ upd[i] = { ...upd[i], prompt: e.target.value };
929
+ setNewGroupOptions(upd);
930
+ }}
931
+ placeholder="Prompt text appended to message when selected (can be empty)"
932
+ 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"
933
+ />
934
+ {newGroupOptions.length > 1 && (
935
+ <button
936
+ type="button"
937
+ onClick={() =>
938
+ setNewGroupOptions((prev) =>
939
+ prev.filter((_, j) => j !== i),
940
+ )
941
+ }
942
+ className="mt-1 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
943
+ >
944
+ <Trash2 className="w-3.5 h-3.5" />
945
+ </button>
946
+ )}
947
+ </div>
948
+ ))}
949
+ <button
950
+ type="button"
951
+ onClick={() =>
952
+ setNewGroupOptions((prev) => [
953
+ ...prev,
954
+ { key: "", prompt: "" },
955
+ ])
956
+ }
957
+ className="flex items-center gap-1 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
958
+ >
959
+ <Plus className="w-3.5 h-3.5" /> Add option row
960
+ </button>
961
+ </div>
962
+
963
+ <div className="flex gap-2 pt-1">
964
+ <button
965
+ type="button"
966
+ onClick={addNewGroup}
967
+ disabled={
968
+ !newGroupKey.trim() ||
969
+ newGroupKey in draft.promptGroups ||
970
+ newGroupOptions.every((o) => !o.key.trim())
971
+ }
972
+ className="px-4 py-1.5 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
973
+ >
974
+ Create group
975
+ </button>
976
+ <button
977
+ type="button"
978
+ onClick={resetNewGroupForm}
979
+ className="px-4 py-1.5 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
980
+ >
981
+ Cancel
982
+ </button>
983
+ </div>
984
+ </div>
985
+ ) : (
986
+ <button
987
+ type="button"
988
+ onClick={() => setShowNewGroupForm(true)}
989
+ 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"
990
+ >
991
+ <Plus className="w-4 h-4" /> Add setting group
992
+ </button>
993
+ )}
994
+
995
+ {/* ── Viz Guide ─────────────────────────────────────── */}
996
+ <Section
997
+ title="Viz Guide (returned by getVizGuide tool)"
998
+ defaultOpen={false}
999
+ >
1000
+ <p className="text-xs text-slate-500 -mt-1">
1001
+ The full VizCraft spec reference the AI receives when it calls{" "}
1002
+ <code className="text-cyan-400">getVizGuide()</code>.
1003
+ </p>
1004
+ <Textarea
1005
+ value={draft.vizGuide}
1006
+ onChange={(v) => patchTop("vizGuide", v)}
1007
+ rows={16}
1008
+ mono
1009
+ />
1010
+ </Section>
1011
+ </div>
1012
+
1013
+ {/* Footer */}
1014
+ <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-700 shrink-0">
1015
+ <button
1016
+ type="button"
1017
+ onClick={closeSettings}
1018
+ className="px-4 py-2 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
1019
+ >
1020
+ Cancel
1021
+ </button>
1022
+ <button
1023
+ type="button"
1024
+ onClick={handleSave}
1025
+ disabled={saving}
1026
+ 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"
1027
+ >
1028
+ {saving ? (
1029
+ <Loader2 className="w-4 h-4 animate-spin" />
1030
+ ) : saved ? (
1031
+ <Check className="w-4 h-4" />
1032
+ ) : (
1033
+ <Save className="w-4 h-4" />
1034
+ )}
1035
+ {saved ? "Saved!" : "Save changes"}
1036
+ </button>
1037
+ </div>
1038
+ </div>
1039
+ </div>
1040
+ );
1041
+ }