create-interview-cockpit 0.4.0 → 0.6.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 (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +753 -1
  3. package/template/client/package.json +4 -0
  4. package/template/client/src/App.tsx +20 -0
  5. package/template/client/src/api.ts +455 -3
  6. package/template/client/src/components/AiSettingsModal.tsx +855 -248
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +132 -27
  9. package/template/client/src/components/ChatView.tsx +365 -123
  10. package/template/client/src/components/CodeContextPanel.tsx +714 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
  13. package/template/client/src/components/DocRefModal.tsx +551 -0
  14. package/template/client/src/components/FileAttachments.tsx +128 -12
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  18. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  19. package/template/client/src/components/MarkdownRenderer.tsx +219 -2
  20. package/template/client/src/components/NotesModal.tsx +977 -0
  21. package/template/client/src/components/PlotEmbed.tsx +173 -0
  22. package/template/client/src/components/Sidebar.tsx +397 -127
  23. package/template/client/src/components/TextAnnotator.tsx +8 -15
  24. package/template/client/src/components/VizCraftEmbed.tsx +412 -25
  25. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  26. package/template/client/src/infraLab.ts +124 -0
  27. package/template/client/src/reactLab.ts +477 -0
  28. package/template/client/src/store.ts +416 -2
  29. package/template/client/src/types.ts +41 -1
  30. package/template/client/tsconfig.tsbuildinfo +1 -1
  31. package/template/cockpit.json +1 -1
  32. package/template/package.json +1 -1
  33. package/template/server/src/google-drive.ts +144 -2
  34. package/template/server/src/index.ts +1890 -188
  35. package/template/server/src/infra-runner.ts +1104 -0
  36. package/template/server/src/storage.ts +274 -3
@@ -8,6 +8,12 @@ import {
8
8
  Loader2,
9
9
  ZoomIn,
10
10
  ZoomOut,
11
+ Send,
12
+ MessageSquare,
13
+ Undo2,
14
+ Copy,
15
+ Check,
16
+ Download,
11
17
  } from "lucide-react";
12
18
  import { parse as parseYaml } from "yaml";
13
19
  import {
@@ -73,6 +79,21 @@ const VIZ_DARK_THEME_CSS = `
73
79
  .viz-embed-host .viz-anim-flow .viz-edge {
74
80
  stroke: #0ea5e9;
75
81
  }
82
+
83
+ /* Tooltip dark-theme overrides */
84
+ .viz-tooltip {
85
+ background: #1e293b !important;
86
+ border: 1px solid #334155 !important;
87
+ color: #e2e8f0 !important;
88
+ font-size: 12px !important;
89
+ font-family: ui-sans-serif, system-ui, sans-serif !important;
90
+ border-radius: 6px !important;
91
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5) !important;
92
+ padding: 6px 10px !important;
93
+ max-width: 280px !important;
94
+ white-space: pre-wrap !important;
95
+ word-break: break-word !important;
96
+ }
76
97
  `;
77
98
 
78
99
  // Inject VizCraft default CSS + dark overrides once into <head>
@@ -86,8 +107,80 @@ function ensureVizCss() {
86
107
  document.head.appendChild(style);
87
108
  }
88
109
 
110
+ /**
111
+ * Replace typographic characters and structural issues that the YAML parser mishandles.
112
+ *
113
+ * Fixes applied (in order):
114
+ * 1. Typographic characters → ASCII equivalents
115
+ * 2. Single-quoted YAML scalar values → double-quoted
116
+ * yaml v2 (YAML 1.2) is strict: `fill: '#fff'` inside flow maps can fail.
117
+ * Double quotes are always safe.
118
+ * 3. Inline flow maps `{ ... }` that the LLM split across multiple lines are
119
+ * rejoined onto a single line. yaml v2 requires a flow-map opening `{` and
120
+ * its matching `}` to reside on the same line when inside a block sequence.
121
+ */
122
+ function sanitizeSpecText(raw: string): string {
123
+ let s = raw
124
+ .replace(/\u2013/g, "-") // en-dash –
125
+ .replace(/\u2014/g, "-") // em-dash —
126
+ .replace(/[\u201C\u201D]/g, '"') // curly double quotes " "
127
+ .replace(/[\u2018\u2019\u02BC]/g, "'"); // curly single quotes ' '
128
+
129
+ // Convert single-quoted YAML scalar values to double-quoted.
130
+ // e.g. fill: '#1e40af' → fill: "#1e40af"
131
+ s = s.replace(/(:\s*)'([^']*)'/g, (_, colon, val) => `${colon}"${val}"`);
132
+
133
+ // Rejoin flow-map items broken across lines.
134
+ // A YAML sequence item that opens with `{` but doesn't close with `}` on the
135
+ // same line will cause yaml v2 to throw "Flow map … must end with a }".
136
+ const lines = s.split("\n");
137
+ const joined: string[] = [];
138
+ let pending = "";
139
+ let depth = 0;
140
+ for (const line of lines) {
141
+ if (depth === 0) {
142
+ let d = 0;
143
+ for (const ch of line) {
144
+ if (ch === "{" || ch === "[") d++;
145
+ else if (ch === "}" || ch === "]") d--;
146
+ }
147
+ if (d > 0) {
148
+ pending = line;
149
+ depth = d;
150
+ } else {
151
+ joined.push(line);
152
+ }
153
+ } else {
154
+ const trimmed = line.trimStart();
155
+ // Insert a comma separator when joining flow-map / flow-sequence items
156
+ // that the LLM split across lines without a trailing comma.
157
+ // e.g. `{ width: 960` + `height: 560 }` → `{ width: 960, height: 560 }`
158
+ const tail = pending.trimEnd().slice(-1);
159
+ const needsComma =
160
+ tail !== "," &&
161
+ tail !== "{" &&
162
+ tail !== "[" &&
163
+ trimmed[0] !== "}" &&
164
+ trimmed[0] !== "]";
165
+ pending += (needsComma ? ", " : " ") + trimmed;
166
+ for (const ch of line) {
167
+ if (ch === "{" || ch === "[") depth++;
168
+ else if (ch === "}" || ch === "]") depth--;
169
+ }
170
+ if (depth <= 0) {
171
+ joined.push(pending);
172
+ pending = "";
173
+ depth = 0;
174
+ }
175
+ }
176
+ }
177
+ if (pending) joined.push(pending);
178
+
179
+ return joined.join("\n");
180
+ }
181
+
89
182
  function parseSpec(raw: string): VizSpec {
90
- const trimmed = raw.trim();
183
+ const trimmed = sanitizeSpecText(raw.trim());
91
184
  // Try JSON first, then YAML
92
185
  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
93
186
  return JSON.parse(trimmed) as VizSpec;
@@ -98,6 +191,8 @@ function parseSpec(raw: string): VizSpec {
98
191
  /**
99
192
  * Walk each VizNode in the (already-built) scene and inject a sensible
100
193
  * maxWidth + overflow on any label that doesn't have one yet.
194
+ * Also injects a tooltip with the full label text so truncated nodes
195
+ * reveal their complete contents on hover.
101
196
  *
102
197
  * We read the shape from the built VizNode (which uses the internal NodeShape
103
198
  * discriminated union, e.g. {kind:'rect', w:120, h:40}), not from VizSpec.
@@ -105,12 +200,17 @@ function parseSpec(raw: string): VizSpec {
105
200
  function injectLabelMaxWidth(builder: ReturnType<typeof fromSpec>): void {
106
201
  const scene = builder.build();
107
202
  for (const node of scene.nodes) {
108
- if (
109
- !node.label ||
203
+ if (!node.label) continue;
204
+ const alreadyHasMaxWidth =
110
205
  (node.label as VizNode["label"] & { maxWidth?: number })?.maxWidth !==
111
- undefined
112
- )
206
+ undefined;
207
+ if (alreadyHasMaxWidth) {
208
+ // Still inject tooltip if there isn't one
209
+ if (!node.tooltip) {
210
+ builder.updateNode(node.id, { tooltip: node.label.text });
211
+ }
113
212
  continue;
213
+ }
114
214
  const shape = node.shape as Record<string, unknown>;
115
215
  let w = 60;
116
216
  if (typeof shape.w === "number") w = shape.w;
@@ -126,6 +226,8 @@ function injectLabelMaxWidth(builder: ReturnType<typeof fromSpec>): void {
126
226
  (node.label as { overflow?: "visible" | "ellipsis" | "clip" })
127
227
  .overflow ?? "ellipsis",
128
228
  },
229
+ // Show full label on hover so truncated "..." nodes are readable
230
+ tooltip: node.tooltip ?? node.label.text,
129
231
  });
130
232
  }
131
233
  }
@@ -139,9 +241,11 @@ interface StepState {
139
241
 
140
242
  interface Props {
141
243
  spec: string;
244
+ /** Called after the user successfully refines the spec so the parent can persist it. */
245
+ onSpecRefined?: (originalSpec: string, newSpec: string) => void;
142
246
  }
143
247
 
144
- export default memo(function VizCraftEmbed({ spec }: Props) {
248
+ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
145
249
  const containerRef = useRef<HTMLDivElement>(null);
146
250
  const controllerRef = useRef<MountController | StepController | null>(null);
147
251
  // Tracks the active step's MountController (set in custom step mode) so panZoom is accessible
@@ -150,10 +254,17 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
150
254
  const [error, setError] = useState<string | null>(null);
151
255
  const [fixing, setFixing] = useState(false);
152
256
  const [stepState, setStepState] = useState<StepState | null>(null);
257
+ const [refineInput, setRefineInput] = useState("");
258
+ const [refining, setRefining] = useState(false);
259
+ const [refineHistory, setRefineHistory] = useState<
260
+ Array<{ prompt: string; spec: string }>
261
+ >([]);
262
+ const [copiedSpec, setCopiedSpec] = useState(false);
153
263
 
154
264
  // Keep activeSpec in sync when the prop changes (streaming / message reload)
155
265
  useEffect(() => {
156
266
  setActiveSpec(spec);
267
+ setRefineHistory([]);
157
268
  }, [spec]);
158
269
 
159
270
  const handleFix = async () => {
@@ -166,7 +277,19 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
166
277
  });
167
278
  if (!res.ok) throw new Error("Fix request failed");
168
279
  const { spec: fixed } = (await res.json()) as { spec: string };
280
+ // Guard against truncated responses — a complete spec always ends with a
281
+ // complete value (not mid-token). Check the last non-whitespace char.
282
+ const lastChar = fixed.trimEnd().slice(-1);
283
+ const looksComplete = lastChar === "}" || /[\w"']/.test(lastChar);
284
+ if (!looksComplete) {
285
+ setError(
286
+ "Fix response was truncated before completing. Try again — the model may need another attempt for this large spec.",
287
+ );
288
+ return;
289
+ }
169
290
  setActiveSpec(fixed);
291
+ // Persist the fix so it survives reload (mirrors handleRefine behaviour)
292
+ onSpecRefined?.(spec, fixed);
170
293
  } catch (err) {
171
294
  console.error("Fix viz error:", err);
172
295
  } finally {
@@ -174,6 +297,165 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
174
297
  }
175
298
  };
176
299
 
300
+ const handleRefine = async (e: React.FormEvent) => {
301
+ e.preventDefault();
302
+ const prompt = refineInput.trim();
303
+ if (!prompt || refining) return;
304
+ setRefining(true);
305
+ try {
306
+ const res = await fetch("/api/refine-viz", {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify({
310
+ spec: activeSpec,
311
+ prompt,
312
+ history: refineHistory,
313
+ }),
314
+ });
315
+ if (!res.ok) throw new Error("Refine request failed");
316
+ const { spec: refined } = (await res.json()) as { spec: string };
317
+ setRefineHistory((prev) => [...prev, { prompt, spec: activeSpec }]);
318
+ onSpecRefined?.(spec, refined);
319
+ setActiveSpec(refined);
320
+ setRefineInput("");
321
+ } catch (err) {
322
+ console.error("Refine viz error:", err);
323
+ } finally {
324
+ setRefining(false);
325
+ }
326
+ };
327
+
328
+ const handleUndoRefine = () => {
329
+ if (refineHistory.length === 0) return;
330
+ const prev = refineHistory[refineHistory.length - 1];
331
+ setActiveSpec(prev.spec);
332
+ setRefineHistory((h) => h.slice(0, -1));
333
+ };
334
+
335
+ const handleCopySpec = async () => {
336
+ try {
337
+ await navigator.clipboard.writeText(activeSpec);
338
+ setCopiedSpec(true);
339
+ setTimeout(() => setCopiedSpec(false), 1800);
340
+ } catch {
341
+ // fallback: select a textarea
342
+ const ta = document.createElement("textarea");
343
+ ta.value = activeSpec;
344
+ ta.style.position = "fixed";
345
+ ta.style.opacity = "0";
346
+ document.body.appendChild(ta);
347
+ ta.select();
348
+ document.execCommand("copy");
349
+ document.body.removeChild(ta);
350
+ setCopiedSpec(true);
351
+ setTimeout(() => setCopiedSpec(false), 1800);
352
+ }
353
+ };
354
+
355
+ const handleDownloadPng = () => {
356
+ const container = containerRef.current;
357
+ if (!container) return;
358
+ const svgEl = container.querySelector("svg");
359
+ if (!svgEl) return;
360
+
361
+ // ── 1. Clone, then inline every element's computed styles ────────────────
362
+ // CSS class selectors (e.g. .viz-embed-host .viz-node-shape) don't resolve
363
+ // once the SVG is detached from the page, so we must bake in computed values.
364
+ const clone = svgEl.cloneNode(true) as SVGSVGElement;
365
+ const origEls = Array.from(svgEl.querySelectorAll("*"));
366
+ const cloneEls = Array.from(clone.querySelectorAll("*")) as SVGElement[];
367
+ const INLINE_PROPS = [
368
+ "fill",
369
+ "stroke",
370
+ "stroke-width",
371
+ "stroke-dasharray",
372
+ "stroke-dashoffset",
373
+ "stroke-opacity",
374
+ "fill-opacity",
375
+ "opacity",
376
+ "font-size",
377
+ "font-family",
378
+ "font-weight",
379
+ "text-anchor",
380
+ "dominant-baseline",
381
+ "alignment-baseline",
382
+ "stroke-linecap",
383
+ "stroke-linejoin",
384
+ ];
385
+ origEls.forEach((orig, i) => {
386
+ if (i >= cloneEls.length) return;
387
+ const cs = window.getComputedStyle(orig);
388
+ INLINE_PROPS.forEach((p) => {
389
+ const v = cs.getPropertyValue(p);
390
+ if (v) cloneEls[i].style.setProperty(p, v);
391
+ });
392
+ });
393
+
394
+ // ── 2. Compute tight content bounds via getBBox on the panzoom group ─────
395
+ // The panzoom group's getBBox returns bounds in its own local coordinate
396
+ // space (before the translate/scale transform). By removing that transform
397
+ // and setting viewBox to those bounds we get a perfectly framed export.
398
+ const panGroup = Array.from(svgEl.children).find(
399
+ (el) => el.tagName.toLowerCase() === "g",
400
+ ) as SVGGraphicsElement | undefined;
401
+ const pad = 24;
402
+ let vx = -pad,
403
+ vy = -pad,
404
+ vw = 800,
405
+ vh = 500;
406
+ if (panGroup) {
407
+ try {
408
+ const bb = panGroup.getBBox();
409
+ if (bb.width > 0 && bb.height > 0) {
410
+ vx = bb.x - pad;
411
+ vy = bb.y - pad;
412
+ vw = bb.width + pad * 2;
413
+ vh = bb.height + pad * 2;
414
+ }
415
+ } catch {
416
+ /* getBBox unavailable — fall back to defaults */
417
+ }
418
+ }
419
+
420
+ // Remove the live pan/zoom transform from the clone — viewBox does the framing
421
+ const clonePanGroup = Array.from(clone.children).find(
422
+ (el) => el.tagName.toLowerCase() === "g",
423
+ );
424
+ if (clonePanGroup) clonePanGroup.removeAttribute("transform");
425
+
426
+ // ── 3. Set output dimensions — target ~1600 px wide, max 4× scale ────────
427
+ const TARGET_W = 1600;
428
+ const renderScale = Math.max(1, Math.min(4, TARGET_W / vw));
429
+ const outW = Math.round(vw * renderScale);
430
+ const outH = Math.round(vh * renderScale);
431
+ clone.setAttribute("viewBox", `${vx} ${vy} ${vw} ${vh}`);
432
+ clone.setAttribute("width", String(outW));
433
+ clone.setAttribute("height", String(outH));
434
+
435
+ // ── 4. Serialize → canvas → PNG download ─────────────────────────────────
436
+ const svgStr = new XMLSerializer().serializeToString(clone);
437
+ const blob = new Blob([svgStr], { type: "image/svg+xml;charset=utf-8" });
438
+ const url = URL.createObjectURL(blob);
439
+
440
+ const img = new Image();
441
+ img.onload = () => {
442
+ const canvas = document.createElement("canvas");
443
+ canvas.width = outW;
444
+ canvas.height = outH;
445
+ const ctx = canvas.getContext("2d")!;
446
+ ctx.fillStyle = "#0f172a";
447
+ ctx.fillRect(0, 0, outW, outH);
448
+ ctx.drawImage(img, 0, 0);
449
+ URL.revokeObjectURL(url);
450
+ const a = document.createElement("a");
451
+ a.href = canvas.toDataURL("image/png");
452
+ a.download = "diagram.png";
453
+ a.click();
454
+ };
455
+ img.onerror = () => URL.revokeObjectURL(url);
456
+ img.src = url;
457
+ };
458
+
177
459
  // Prevent the parent chat scroll container from scrolling when the user
178
460
  // wheels over the viz. We need a non-passive listener so preventDefault works.
179
461
  // (React's synthetic onWheel is passive in some environments and can't prevent scroll.)
@@ -245,24 +527,43 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
245
527
  };
246
528
  };
247
529
 
530
+ // Timer to unblock "Next" if signals never complete (e.g. invalid edge direction)
531
+ let signalReadyTimer: ReturnType<typeof setTimeout> | null = null;
532
+ const clearSignalTimer = () => {
533
+ if (signalReadyTimer !== null) {
534
+ clearTimeout(signalReadyTimer);
535
+ signalReadyTimer = null;
536
+ }
537
+ };
538
+
248
539
  const goTo = (index: number) => {
249
540
  if (cancelled || index < 0 || index >= total) return;
541
+ clearSignalTimer();
250
542
  // Preserve the current viewport so navigating steps doesn't reset zoom/pan.
251
543
  // getState() is only available after the first mount (prevState is undefined on step 0).
252
544
  const prevState = mountControllerRef.current?.panZoom?.getState();
253
545
  currentMount?.destroy();
254
546
  currentMount = null;
255
547
 
256
- const builder = fromSpec(buildStepSpec(index));
257
- injectLabelMaxWidth(builder);
258
- currentMount = builder.mount(container, {
259
- panZoom: true,
260
- // Restore previous zoom if the user had already panned/zoomed; otherwise fit.
261
- initialZoom: prevState?.zoom ?? "fit",
262
- initialPan: prevState?.pan,
263
- minZoom: 0.1,
264
- maxZoom: 8,
265
- });
548
+ // Wrap VizCraft mount in try-catch — runtime errors (e.g. unknown node in chain)
549
+ // are displayed in the error panel rather than silently freezing the component.
550
+ try {
551
+ const builder = fromSpec(buildStepSpec(index));
552
+ injectLabelMaxWidth(builder);
553
+ currentMount = builder.mount(container, {
554
+ panZoom: true,
555
+ // Restore previous zoom if the user had already panned/zoomed; otherwise fit.
556
+ initialZoom: prevState?.zoom ?? "fit",
557
+ initialPan: prevState?.pan,
558
+ minZoom: 0.1,
559
+ maxZoom: 8,
560
+ });
561
+ } catch (e) {
562
+ setError(
563
+ e instanceof Error ? e.message : "Failed to render step",
564
+ );
565
+ return;
566
+ }
266
567
  mountControllerRef.current = currentMount;
267
568
  currentIndex = index;
268
569
 
@@ -281,15 +582,23 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
281
582
  if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
282
583
  } else {
283
584
  let done = 0;
585
+ const onComplete = () => {
586
+ done++;
587
+ if (done >= signals.length) {
588
+ clearSignalTimer();
589
+ setStepState((prev) => prev && { ...prev, isReady: true });
590
+ if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
591
+ }
592
+ };
284
593
  signals.forEach((s) => {
285
- currentMount!.onSignalComplete(s.id, () => {
286
- done++;
287
- if (done >= signals.length) {
288
- setStepState((prev) => prev && { ...prev, isReady: true });
289
- if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
290
- }
291
- });
594
+ currentMount!.onSignalComplete(s.id, onComplete);
292
595
  });
596
+ // Fallback: if signals never fire (e.g. signal chain travels against edge direction),
597
+ // unblock the Next button after 6 s so the user isn't permanently stuck.
598
+ signalReadyTimer = setTimeout(() => {
599
+ signalReadyTimer = null;
600
+ setStepState((prev) => prev && { ...prev, isReady: true });
601
+ }, 6000);
293
602
  }
294
603
  };
295
604
 
@@ -302,6 +611,7 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
302
611
  reset: () => goTo(0),
303
612
  destroy: () => {
304
613
  cancelled = true;
614
+ clearSignalTimer();
305
615
  currentMount?.destroy();
306
616
  currentMount = null;
307
617
  mountControllerRef.current = null;
@@ -370,7 +680,7 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
370
680
  <div className="my-3 rounded-lg overflow-hidden border border-slate-700/50 bg-slate-900/60">
371
681
  {/* Toolbar — zoom controls (always shown unless there's an error) */}
372
682
  {!error && (
373
- <div className="flex items-center justify-end gap-1 px-2 pt-1.5 pb-0">
683
+ <div className="relative z-10 flex items-center justify-end gap-1 px-2 pt-1.5 pb-0">
374
684
  {/* Zoom out */}
375
685
  <button
376
686
  onClick={handleZoomOut}
@@ -398,6 +708,30 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
398
708
  <Maximize2 className="w-3 h-3" />
399
709
  Fit
400
710
  </button>
711
+ {/* Divider */}
712
+ <span className="w-px h-3 bg-slate-700 mx-0.5" />
713
+ {/* Copy spec */}
714
+ <button
715
+ onClick={handleCopySpec}
716
+ title="Copy diagram spec"
717
+ 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"
718
+ >
719
+ {copiedSpec ? (
720
+ <Check className="w-3 h-3 text-green-400" />
721
+ ) : (
722
+ <Copy className="w-3 h-3" />
723
+ )}
724
+ {copiedSpec ? "Copied!" : "Copy"}
725
+ </button>
726
+ {/* Download PNG */}
727
+ <button
728
+ onClick={handleDownloadPng}
729
+ title="Download as PNG"
730
+ 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"
731
+ >
732
+ <Download className="w-3 h-3" />
733
+ PNG
734
+ </button>
401
735
  </div>
402
736
  )}
403
737
 
@@ -442,7 +776,7 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
442
776
 
443
777
  {/* Step controls — only rendered when spec has steps */}
444
778
  {stepState && !error && (
445
- <div className="flex items-center justify-between px-3 py-2 border-t border-slate-700/50 bg-slate-800/60 gap-3">
779
+ <div className="relative z-10 flex items-center justify-between px-3 py-2 border-t border-slate-700/50 bg-slate-800/60 gap-3">
446
780
  {/* Prev */}
447
781
  <button
448
782
  onClick={handlePrev}
@@ -497,6 +831,59 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
497
831
  )}
498
832
  </div>
499
833
  )}
834
+
835
+ {/* Refine panel */}
836
+ <div className="relative z-10 border-t border-slate-700/50">
837
+ {/* History chips */}
838
+ {refineHistory.length > 0 && (
839
+ <div className="flex flex-wrap items-center gap-1.5 px-3 pt-2">
840
+ {refineHistory.map((h, i) => (
841
+ <span
842
+ key={i}
843
+ 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]"
844
+ title={h.prompt}
845
+ >
846
+ <MessageSquare className="w-2.5 h-2.5 flex-shrink-0" />
847
+ <span className="truncate">{h.prompt}</span>
848
+ </span>
849
+ ))}
850
+ <button
851
+ onClick={handleUndoRefine}
852
+ 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"
853
+ title="Undo last refinement"
854
+ >
855
+ <Undo2 className="w-2.5 h-2.5" />
856
+ Undo
857
+ </button>
858
+ </div>
859
+ )}
860
+ {/* Prompt input row */}
861
+ <form
862
+ onSubmit={handleRefine}
863
+ className="flex items-center gap-2 px-3 py-2"
864
+ >
865
+ <input
866
+ type="text"
867
+ value={refineInput}
868
+ onChange={(e) => setRefineInput(e.target.value)}
869
+ placeholder="Describe a change… e.g. add a database node"
870
+ 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"
871
+ disabled={refining}
872
+ />
873
+ <button
874
+ type="submit"
875
+ disabled={refining || !refineInput.trim()}
876
+ 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"
877
+ title="Refine diagram"
878
+ >
879
+ {refining ? (
880
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
881
+ ) : (
882
+ <Send className="w-3.5 h-3.5" />
883
+ )}
884
+ </button>
885
+ </form>
886
+ </div>
500
887
  </div>
501
888
  );
502
889
  });
@@ -369,6 +369,10 @@ export default function WorkspaceSwitcher() {
369
369
  folderPicker.ws.id,
370
370
  name,
371
371
  );
372
+ if ("needsAuth" in created && created.needsAuth) {
373
+ window.location.href = created.authUrl;
374
+ return;
375
+ }
372
376
  setFolderPicker((p) =>
373
377
  p ? { ...p, folders: [...p.folders, created] } : p,
374
378
  );