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
@@ -12,9 +12,8 @@ interface Props {
12
12
  onAnnotationUpdate: (updated: Annotation) => void;
13
13
  bookmarkedBlockIndex?: number;
14
14
  onBookmarkBlock?: (blockIndex: number) => void;
15
- responseLength?: string;
16
- responseStyle?: string;
17
- responseAudience?: string;
15
+ preferenceSuffix?: string;
16
+ onSpecRefined?: (originalSpec: string, newSpec: string) => void;
18
17
  }
19
18
 
20
19
  type Phase = "idle" | "button" | "input" | "loading";
@@ -119,9 +118,8 @@ export default function TextAnnotator({
119
118
  onAnnotationUpdate,
120
119
  bookmarkedBlockIndex,
121
120
  onBookmarkBlock,
122
- responseLength,
123
- responseStyle,
124
- responseAudience,
121
+ preferenceSuffix,
122
+ onSpecRefined,
125
123
  }: Props) {
126
124
  const containerRef = useRef<HTMLDivElement>(null);
127
125
  const annotationsRef = useRef(annotations);
@@ -207,9 +205,7 @@ export default function TextAnnotator({
207
205
  selectedText,
208
206
  prompt: inputValue.trim(),
209
207
  messageContent: content,
210
- responseLength,
211
- responseStyle,
212
- responseAudience,
208
+ preferenceSuffix,
213
209
  }),
214
210
  });
215
211
  const data = await res.json();
@@ -248,9 +244,7 @@ export default function TextAnnotator({
248
244
  messageContent: content,
249
245
  priorResponse: annotation.response,
250
246
  followUps: annotation.followUps ?? [],
251
- responseLength,
252
- responseStyle,
253
- responseAudience,
247
+ preferenceSuffix,
254
248
  }),
255
249
  });
256
250
  const data = await res.json();
@@ -288,6 +282,7 @@ export default function TextAnnotator({
288
282
  onAnnotationClick={handleAnnotationClick}
289
283
  bookmarkedBlockIndex={bookmarkedBlockIndex}
290
284
  onBookmarkBlock={onBookmarkBlock}
285
+ onSpecRefined={onSpecRefined}
291
286
  />
292
287
 
293
288
  {/* Annotation dialog — opened by clicking an underlined annotation link */}
@@ -298,9 +293,7 @@ export default function TextAnnotator({
298
293
  onUpdate={onAnnotationUpdate}
299
294
  messageContent={content}
300
295
  initialPos={dialogPos}
301
- responseLength={responseLength}
302
- responseStyle={responseStyle}
303
- responseAudience={responseAudience}
296
+ preferenceSuffix={preferenceSuffix}
304
297
  />
305
298
  )}
306
299
 
@@ -0,0 +1,645 @@
1
+ import { memo, useEffect, useRef, useState } from "react";
2
+ import {
3
+ ChevronLeft,
4
+ ChevronRight,
5
+ RotateCcw,
6
+ Maximize2,
7
+ RefreshCw,
8
+ Loader2,
9
+ ZoomIn,
10
+ ZoomOut,
11
+ Send,
12
+ MessageSquare,
13
+ Undo2,
14
+ } from "lucide-react";
15
+ import { parse as parseYaml } from "yaml";
16
+ import {
17
+ fromSpec,
18
+ DEFAULT_VIZ_CSS,
19
+ type VizSpec,
20
+ type MountController,
21
+ type StepController,
22
+ type VizNode,
23
+ type PanZoomController,
24
+ } from "vizcraft";
25
+
26
+ // Dark-theme overrides for VizCraft — applied on top of DEFAULT_VIZ_CSS
27
+ const VIZ_DARK_THEME_CSS = `
28
+ /* ── VizCraft dark-theme overrides ── */
29
+
30
+ /* Allow pan/zoom on empty SVG space; set grab cursor */
31
+ .viz-embed-host svg {
32
+ pointer-events: all;
33
+ cursor: grab;
34
+ }
35
+ .viz-embed-host svg:active {
36
+ cursor: grabbing;
37
+ }
38
+ /* Block browser touch-scroll so VizCraft pan/zoom can take over */
39
+ .viz-embed-host {
40
+ touch-action: none;
41
+ user-select: none;
42
+ cursor: grab;
43
+ }
44
+ .viz-embed-host:active {
45
+ cursor: grabbing;
46
+ }
47
+
48
+ .viz-embed-host .viz-node-shape {
49
+ fill: #1e293b;
50
+ stroke: #475569;
51
+ stroke-width: 1.5;
52
+ }
53
+ .viz-embed-host .viz-node-label {
54
+ fill: #e2e8f0;
55
+ font-size: 12px;
56
+ font-family: ui-sans-serif, system-ui, sans-serif;
57
+ }
58
+ .viz-embed-host .viz-edge {
59
+ stroke: #64748b;
60
+ stroke-width: 1.5;
61
+ }
62
+ .viz-embed-host .viz-edge-label {
63
+ fill: #94a3b8;
64
+ font-size: 11px;
65
+ font-family: ui-sans-serif, system-ui, sans-serif;
66
+ }
67
+ .viz-embed-host .viz-grid-label {
68
+ fill: #64748b;
69
+ }
70
+ .viz-embed-host .viz-signal {
71
+ fill: #38bdf8;
72
+ }
73
+ .viz-embed-host .viz-signal:hover {
74
+ fill: #7dd3fc;
75
+ }
76
+ .viz-embed-host .viz-anim-flow .viz-edge {
77
+ stroke: #0ea5e9;
78
+ }
79
+ `;
80
+
81
+ // Inject VizCraft default CSS + dark overrides once into <head>
82
+ let cssInjected = false;
83
+ function ensureVizCss() {
84
+ if (cssInjected) return;
85
+ cssInjected = true;
86
+ const style = document.createElement("style");
87
+ style.id = "vizcraft-css";
88
+ style.textContent = DEFAULT_VIZ_CSS + VIZ_DARK_THEME_CSS;
89
+ document.head.appendChild(style);
90
+ }
91
+
92
+ /**
93
+ * Replace typographic characters that the YAML parser mishandles:
94
+ * - en-dash (U+2013) and em-dash (U+2014) → plain hyphen
95
+ * - left/right curly double-quotes → straight double-quote
96
+ * - left/right curly single-quotes / apostrophes → straight single-quote
97
+ *
98
+ * These are commonly injected by LLMs or copy-paste from rich text.
99
+ */
100
+ function sanitizeSpecText(raw: string): string {
101
+ return raw
102
+ .replace(/\u2013/g, "-") // en-dash –
103
+ .replace(/\u2014/g, "-") // em-dash —
104
+ .replace(/[\u201C\u201D]/g, '"') // curly double quotes " "
105
+ .replace(/[\u2018\u2019\u02BC]/g, "'"); // curly single quotes ' '
106
+ }
107
+
108
+ function parseSpec(raw: string): VizSpec {
109
+ const trimmed = sanitizeSpecText(raw.trim());
110
+ // Try JSON first, then YAML
111
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
112
+ return JSON.parse(trimmed) as VizSpec;
113
+ }
114
+ return parseYaml(trimmed) as VizSpec;
115
+ }
116
+
117
+ /**
118
+ * Walk each VizNode in the (already-built) scene and inject a sensible
119
+ * maxWidth + overflow on any label that doesn't have one yet.
120
+ *
121
+ * We read the shape from the built VizNode (which uses the internal NodeShape
122
+ * discriminated union, e.g. {kind:'rect', w:120, h:40}), not from VizSpec.
123
+ */
124
+ function injectLabelMaxWidth(builder: ReturnType<typeof fromSpec>): void {
125
+ const scene = builder.build();
126
+ for (const node of scene.nodes) {
127
+ if (
128
+ !node.label ||
129
+ (node.label as VizNode["label"] & { maxWidth?: number })?.maxWidth !==
130
+ undefined
131
+ )
132
+ continue;
133
+ const shape = node.shape as Record<string, unknown>;
134
+ let w = 60;
135
+ if (typeof shape.w === "number") w = shape.w;
136
+ else if (typeof shape.r === "number") w = shape.r * 2;
137
+ else if (typeof shape.rx === "number") w = (shape.rx as number) * 2;
138
+ else if (typeof shape.size === "number") w = shape.size as number;
139
+ else if (typeof shape.outerR === "number") w = (shape.outerR as number) * 2;
140
+ builder.updateNode(node.id, {
141
+ label: {
142
+ ...node.label,
143
+ maxWidth: Math.max(w - 10, 20),
144
+ overflow:
145
+ (node.label as { overflow?: "visible" | "ellipsis" | "clip" })
146
+ .overflow ?? "ellipsis",
147
+ },
148
+ });
149
+ }
150
+ }
151
+
152
+ interface StepState {
153
+ index: number;
154
+ total: number;
155
+ label: string;
156
+ isReady: boolean;
157
+ }
158
+
159
+ interface Props {
160
+ spec: string;
161
+ /** Called after the user successfully refines the spec so the parent can persist it. */
162
+ onSpecRefined?: (originalSpec: string, newSpec: string) => void;
163
+ }
164
+
165
+ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
166
+ const containerRef = useRef<HTMLDivElement>(null);
167
+ const controllerRef = useRef<MountController | StepController | null>(null);
168
+ // Tracks the active step's MountController (set in custom step mode) so panZoom is accessible
169
+ const mountControllerRef = useRef<MountController | null>(null);
170
+ const [activeSpec, setActiveSpec] = useState(spec);
171
+ const [error, setError] = useState<string | null>(null);
172
+ const [fixing, setFixing] = useState(false);
173
+ const [stepState, setStepState] = useState<StepState | null>(null);
174
+ const [refineInput, setRefineInput] = useState("");
175
+ const [refining, setRefining] = useState(false);
176
+ const [refineHistory, setRefineHistory] = useState<
177
+ Array<{ prompt: string; spec: string }>
178
+ >([]);
179
+
180
+ // Keep activeSpec in sync when the prop changes (streaming / message reload)
181
+ useEffect(() => {
182
+ setActiveSpec(spec);
183
+ setRefineHistory([]);
184
+ }, [spec]);
185
+
186
+ const handleFix = async () => {
187
+ setFixing(true);
188
+ try {
189
+ const res = await fetch("/api/fix-viz", {
190
+ method: "POST",
191
+ headers: { "Content-Type": "application/json" },
192
+ body: JSON.stringify({ spec: activeSpec, error }),
193
+ });
194
+ if (!res.ok) throw new Error("Fix request failed");
195
+ const { spec: fixed } = (await res.json()) as { spec: string };
196
+ setActiveSpec(fixed);
197
+ } catch (err) {
198
+ console.error("Fix viz error:", err);
199
+ } finally {
200
+ setFixing(false);
201
+ }
202
+ };
203
+
204
+ const handleRefine = async (e: React.FormEvent) => {
205
+ e.preventDefault();
206
+ const prompt = refineInput.trim();
207
+ if (!prompt || refining) return;
208
+ setRefining(true);
209
+ try {
210
+ const res = await fetch("/api/refine-viz", {
211
+ method: "POST",
212
+ headers: { "Content-Type": "application/json" },
213
+ body: JSON.stringify({
214
+ spec: activeSpec,
215
+ prompt,
216
+ history: refineHistory,
217
+ }),
218
+ });
219
+ if (!res.ok) throw new Error("Refine request failed");
220
+ const { spec: refined } = (await res.json()) as { spec: string };
221
+ setRefineHistory((prev) => [...prev, { prompt, spec: activeSpec }]);
222
+ onSpecRefined?.(spec, refined);
223
+ setActiveSpec(refined);
224
+ setRefineInput("");
225
+ } catch (err) {
226
+ console.error("Refine viz error:", err);
227
+ } finally {
228
+ setRefining(false);
229
+ }
230
+ };
231
+
232
+ const handleUndoRefine = () => {
233
+ if (refineHistory.length === 0) return;
234
+ const prev = refineHistory[refineHistory.length - 1];
235
+ setActiveSpec(prev.spec);
236
+ setRefineHistory((h) => h.slice(0, -1));
237
+ };
238
+
239
+ // Prevent the parent chat scroll container from scrolling when the user
240
+ // wheels over the viz. We need a non-passive listener so preventDefault works.
241
+ // (React's synthetic onWheel is passive in some environments and can't prevent scroll.)
242
+ useEffect(() => {
243
+ const el = containerRef.current;
244
+ if (!el) return;
245
+ const stopScroll = (e: WheelEvent) => e.stopPropagation();
246
+ el.addEventListener("wheel", stopScroll, { passive: true });
247
+ return () => el.removeEventListener("wheel", stopScroll);
248
+ }, []);
249
+
250
+ useEffect(() => {
251
+ const container = containerRef.current;
252
+ if (!container) return;
253
+
254
+ // Debounce: wait for streaming to stabilise before mounting
255
+ const timer = setTimeout(() => {
256
+ // Tear down any previous mount
257
+ controllerRef.current?.destroy();
258
+ controllerRef.current = null;
259
+ setError(null);
260
+ setStepState(null);
261
+
262
+ let parsed: VizSpec;
263
+ try {
264
+ parsed = parseSpec(activeSpec);
265
+ } catch (e) {
266
+ setError(e instanceof Error ? e.message : "Invalid spec");
267
+ return;
268
+ }
269
+
270
+ ensureVizCss();
271
+
272
+ try {
273
+ if (parsed.steps?.length) {
274
+ // Step-through mode — custom implementation so each step is mounted
275
+ // with { panZoom: true }, making zoom/fit buttons work in step mode.
276
+ const steps = parsed.steps;
277
+ const total = steps.length;
278
+ let currentIndex = 0;
279
+ let currentMount: MountController | null = null;
280
+ let cancelled = false;
281
+
282
+ // Build a per-step VizSpec: merge node highlights, overlays, and
283
+ // include step-level signals as autoSignals so they auto-play on mount.
284
+ const buildStepSpec = (index: number): VizSpec => {
285
+ const step = steps[index];
286
+ const highlighted = new Set(step.highlight ?? []);
287
+ const nodes = parsed.nodes.map((n) =>
288
+ !highlighted.size || highlighted.has(n.id)
289
+ ? n
290
+ : {
291
+ ...n,
292
+ opacity:
293
+ ((n as unknown as { opacity?: number }).opacity ?? 1) *
294
+ 0.3,
295
+ },
296
+ );
297
+ const overlays = [
298
+ ...(parsed.overlays ?? []),
299
+ ...(step.overlays ?? []),
300
+ ];
301
+ return {
302
+ ...parsed,
303
+ nodes,
304
+ overlays: overlays.length ? overlays : undefined,
305
+ autoSignals: step.signals?.map((s) => ({ ...s, loop: false })),
306
+ steps: undefined,
307
+ };
308
+ };
309
+
310
+ // Timer to unblock "Next" if signals never complete (e.g. invalid edge direction)
311
+ let signalReadyTimer: ReturnType<typeof setTimeout> | null = null;
312
+ const clearSignalTimer = () => {
313
+ if (signalReadyTimer !== null) {
314
+ clearTimeout(signalReadyTimer);
315
+ signalReadyTimer = null;
316
+ }
317
+ };
318
+
319
+ const goTo = (index: number) => {
320
+ if (cancelled || index < 0 || index >= total) return;
321
+ clearSignalTimer();
322
+ // Preserve the current viewport so navigating steps doesn't reset zoom/pan.
323
+ // getState() is only available after the first mount (prevState is undefined on step 0).
324
+ const prevState = mountControllerRef.current?.panZoom?.getState();
325
+ currentMount?.destroy();
326
+ currentMount = null;
327
+
328
+ // Wrap VizCraft mount in try-catch — runtime errors (e.g. unknown node in chain)
329
+ // are displayed in the error panel rather than silently freezing the component.
330
+ try {
331
+ const builder = fromSpec(buildStepSpec(index));
332
+ injectLabelMaxWidth(builder);
333
+ currentMount = builder.mount(container, {
334
+ panZoom: true,
335
+ // Restore previous zoom if the user had already panned/zoomed; otherwise fit.
336
+ initialZoom: prevState?.zoom ?? "fit",
337
+ initialPan: prevState?.pan,
338
+ minZoom: 0.1,
339
+ maxZoom: 8,
340
+ });
341
+ } catch (e) {
342
+ setError(
343
+ e instanceof Error ? e.message : "Failed to render step",
344
+ );
345
+ return;
346
+ }
347
+ mountControllerRef.current = currentMount;
348
+ currentIndex = index;
349
+
350
+ const step = steps[index];
351
+ setStepState({
352
+ index,
353
+ total,
354
+ label: step.label ?? "",
355
+ isReady: false,
356
+ });
357
+
358
+ // Track non-looping signals to know when the step animation completes
359
+ const signals = (step.signals ?? []).filter((s) => !s.loop);
360
+ if (signals.length === 0) {
361
+ setStepState((prev) => prev && { ...prev, isReady: true });
362
+ if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
363
+ } else {
364
+ let done = 0;
365
+ const onComplete = () => {
366
+ done++;
367
+ if (done >= signals.length) {
368
+ clearSignalTimer();
369
+ setStepState((prev) => prev && { ...prev, isReady: true });
370
+ if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
371
+ }
372
+ };
373
+ signals.forEach((s) => {
374
+ currentMount!.onSignalComplete(s.id, onComplete);
375
+ });
376
+ // Fallback: if signals never fire (e.g. signal chain travels against edge direction),
377
+ // unblock the Next button after 6 s so the user isn't permanently stuck.
378
+ signalReadyTimer = setTimeout(() => {
379
+ signalReadyTimer = null;
380
+ setStepState((prev) => prev && { ...prev, isReady: true });
381
+ }, 6000);
382
+ }
383
+ };
384
+
385
+ goTo(0);
386
+
387
+ // Expose a StepController-shaped object for the nav buttons
388
+ controllerRef.current = {
389
+ next: () => goTo(currentIndex + 1),
390
+ prev: () => goTo(currentIndex - 1),
391
+ reset: () => goTo(0),
392
+ destroy: () => {
393
+ cancelled = true;
394
+ clearSignalTimer();
395
+ currentMount?.destroy();
396
+ currentMount = null;
397
+ mountControllerRef.current = null;
398
+ },
399
+ } as unknown as StepController;
400
+ } else {
401
+ // Auto-signal / static mode — inject maxWidth then mount with pan/zoom
402
+ const builder = fromSpec(parsed);
403
+ injectLabelMaxWidth(builder);
404
+ const ctrl = builder.mount(container, {
405
+ panZoom: true,
406
+ initialZoom: "fit",
407
+ minZoom: 0.1,
408
+ maxZoom: 8,
409
+ });
410
+ controllerRef.current = ctrl;
411
+ }
412
+ } catch (e) {
413
+ setError(e instanceof Error ? e.message : "Failed to render");
414
+ }
415
+ }, 400);
416
+
417
+ return () => {
418
+ clearTimeout(timer);
419
+ controllerRef.current?.destroy();
420
+ controllerRef.current = null;
421
+ mountControllerRef.current = null;
422
+ };
423
+ }, [activeSpec]);
424
+
425
+ const handleNext = () => {
426
+ (controllerRef.current as StepController)?.next?.();
427
+ };
428
+ const handlePrev = () => {
429
+ (controllerRef.current as StepController)?.prev?.();
430
+ };
431
+ const handleRestart = () => {
432
+ (controllerRef.current as StepController)?.reset?.();
433
+ };
434
+
435
+ const panZoom = () =>
436
+ // Non-step mode: MountController is stored directly in controllerRef
437
+ // Step mode: each step's MountController is stored in mountControllerRef
438
+ ((controllerRef.current as MountController)?.panZoom ??
439
+ mountControllerRef.current?.panZoom) as PanZoomController | undefined;
440
+
441
+ const handleFitView = () => panZoom()?.fitToContent?.();
442
+
443
+ const handleZoomIn = () => {
444
+ const pz = panZoom();
445
+ if (!pz) return;
446
+ pz.setZoom(Math.min(pz.zoom * 1.3, 8));
447
+ };
448
+
449
+ const handleZoomOut = () => {
450
+ const pz = panZoom();
451
+ if (!pz) return;
452
+ pz.setZoom(Math.max(pz.zoom / 1.3, 0.1));
453
+ };
454
+
455
+ const isFirstStep = stepState?.index === 0;
456
+ const isLastStep =
457
+ stepState != null && stepState.index === stepState.total - 1;
458
+
459
+ return (
460
+ <div className="my-3 rounded-lg overflow-hidden border border-slate-700/50 bg-slate-900/60">
461
+ {/* Toolbar — zoom controls (always shown unless there's an error) */}
462
+ {!error && (
463
+ <div className="flex items-center justify-end gap-1 px-2 pt-1.5 pb-0">
464
+ {/* Zoom out */}
465
+ <button
466
+ onClick={handleZoomOut}
467
+ title="Zoom out"
468
+ className="flex items-center justify-center w-6 h-6 rounded bg-slate-800 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
469
+ >
470
+ <ZoomOut className="w-3.5 h-3.5" />
471
+ </button>
472
+ {/* Zoom in */}
473
+ <button
474
+ onClick={handleZoomIn}
475
+ title="Zoom in"
476
+ className="flex items-center justify-center w-6 h-6 rounded bg-slate-800 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
477
+ >
478
+ <ZoomIn className="w-3.5 h-3.5" />
479
+ </button>
480
+ {/* Divider */}
481
+ <span className="w-px h-3 bg-slate-700 mx-0.5" />
482
+ {/* Fit */}
483
+ <button
484
+ onClick={handleFitView}
485
+ title="Fit to view"
486
+ className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded bg-slate-800 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
487
+ >
488
+ <Maximize2 className="w-3 h-3" />
489
+ Fit
490
+ </button>
491
+ </div>
492
+ )}
493
+
494
+ {/* Canvas */}
495
+ <div className="relative">
496
+ {error ? (
497
+ <div className="p-3">
498
+ <div className="w-full bg-red-500/10 border border-red-500/20 rounded p-3">
499
+ <div className="flex items-center justify-between mb-1.5">
500
+ <p className="text-xs text-red-400">Viz error:</p>
501
+ <button
502
+ onClick={handleFix}
503
+ disabled={fixing}
504
+ className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded bg-slate-700 text-slate-300 hover:bg-slate-600 disabled:opacity-50 transition-colors"
505
+ >
506
+ {fixing ? (
507
+ <>
508
+ <Loader2 className="w-3 h-3 animate-spin" />
509
+ Fixing…
510
+ </>
511
+ ) : (
512
+ <>
513
+ <RefreshCw className="w-3 h-3" />
514
+ Fix diagram
515
+ </>
516
+ )}
517
+ </button>
518
+ </div>
519
+ <pre className="text-xs text-slate-400 whitespace-pre-wrap break-all">
520
+ {error}
521
+ </pre>
522
+ </div>
523
+ </div>
524
+ ) : (
525
+ <div
526
+ ref={containerRef}
527
+ className="viz-embed-host"
528
+ style={{ width: "100%", height: "360px" }}
529
+ />
530
+ )}
531
+ </div>
532
+
533
+ {/* Step controls — only rendered when spec has steps */}
534
+ {stepState && !error && (
535
+ <div className="flex items-center justify-between px-3 py-2 border-t border-slate-700/50 bg-slate-800/60 gap-3">
536
+ {/* Prev */}
537
+ <button
538
+ onClick={handlePrev}
539
+ disabled={isFirstStep}
540
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded bg-slate-700 text-slate-300 hover:bg-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
541
+ >
542
+ <ChevronLeft className="w-3.5 h-3.5" />
543
+ Prev
544
+ </button>
545
+
546
+ {/* Step label + counter */}
547
+ <div className="flex-1 text-center overflow-hidden">
548
+ <span className="text-[11px] text-slate-400 block truncate">
549
+ <span className="text-cyan-400 font-medium mr-1.5">
550
+ {stepState.index + 1} / {stepState.total}
551
+ </span>
552
+ {stepState.label}
553
+ </span>
554
+ {/* Progress dots */}
555
+ <div className="flex justify-center gap-1 mt-1">
556
+ {Array.from({ length: stepState.total }).map((_, i) => (
557
+ <span
558
+ key={i}
559
+ className={`inline-block rounded-full transition-all ${
560
+ i === stepState.index
561
+ ? "w-3 h-1.5 bg-cyan-400"
562
+ : "w-1.5 h-1.5 bg-slate-600"
563
+ }`}
564
+ />
565
+ ))}
566
+ </div>
567
+ </div>
568
+
569
+ {/* Next / Restart */}
570
+ {isLastStep ? (
571
+ <button
572
+ onClick={handleRestart}
573
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition-colors"
574
+ >
575
+ <RotateCcw className="w-3.5 h-3.5" />
576
+ Restart
577
+ </button>
578
+ ) : (
579
+ <button
580
+ onClick={handleNext}
581
+ disabled={!stepState.isReady}
582
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded bg-cyan-700 text-white hover:bg-cyan-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
583
+ >
584
+ Next
585
+ <ChevronRight className="w-3.5 h-3.5" />
586
+ </button>
587
+ )}
588
+ </div>
589
+ )}
590
+
591
+ {/* Refine panel */}
592
+ <div className="border-t border-slate-700/50">
593
+ {/* History chips */}
594
+ {refineHistory.length > 0 && (
595
+ <div className="flex flex-wrap items-center gap-1.5 px-3 pt-2">
596
+ {refineHistory.map((h, i) => (
597
+ <span
598
+ key={i}
599
+ className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-slate-800 text-slate-400 border border-slate-700 max-w-[180px]"
600
+ title={h.prompt}
601
+ >
602
+ <MessageSquare className="w-2.5 h-2.5 flex-shrink-0" />
603
+ <span className="truncate">{h.prompt}</span>
604
+ </span>
605
+ ))}
606
+ <button
607
+ onClick={handleUndoRefine}
608
+ className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-slate-800 text-slate-400 hover:text-amber-300 hover:border-amber-500/40 border border-slate-700 transition-colors"
609
+ title="Undo last refinement"
610
+ >
611
+ <Undo2 className="w-2.5 h-2.5" />
612
+ Undo
613
+ </button>
614
+ </div>
615
+ )}
616
+ {/* Prompt input row */}
617
+ <form
618
+ onSubmit={handleRefine}
619
+ className="flex items-center gap-2 px-3 py-2"
620
+ >
621
+ <input
622
+ type="text"
623
+ value={refineInput}
624
+ onChange={(e) => setRefineInput(e.target.value)}
625
+ placeholder="Describe a change… e.g. add a database node"
626
+ className="flex-1 bg-slate-800 border border-slate-700 rounded px-2.5 py-1.5 text-xs text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-600 transition-colors"
627
+ disabled={refining}
628
+ />
629
+ <button
630
+ type="submit"
631
+ disabled={refining || !refineInput.trim()}
632
+ className="flex items-center justify-center w-7 h-7 rounded bg-cyan-700 text-white hover:bg-cyan-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"
633
+ title="Refine diagram"
634
+ >
635
+ {refining ? (
636
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
637
+ ) : (
638
+ <Send className="w-3.5 h-3.5" />
639
+ )}
640
+ </button>
641
+ </form>
642
+ </div>
643
+ </div>
644
+ );
645
+ });