create-interview-cockpit 0.5.0 → 0.7.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 (30) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +384 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +530 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1895 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +960 -0
  23. package/template/client/src/store.ts +250 -6
  24. package/template/client/src/types.ts +36 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/cockpit.json +1 -1
  27. package/template/server/src/google-drive.ts +39 -3
  28. package/template/server/src/index.ts +954 -52
  29. package/template/server/src/infra-runner.ts +1104 -0
  30. package/template/server/src/storage.ts +22 -3
@@ -11,6 +11,9 @@ import {
11
11
  Send,
12
12
  MessageSquare,
13
13
  Undo2,
14
+ Copy,
15
+ Check,
16
+ Download,
14
17
  } from "lucide-react";
15
18
  import { parse as parseYaml } from "yaml";
16
19
  import {
@@ -76,6 +79,21 @@ const VIZ_DARK_THEME_CSS = `
76
79
  .viz-embed-host .viz-anim-flow .viz-edge {
77
80
  stroke: #0ea5e9;
78
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
+ }
79
97
  `;
80
98
 
81
99
  // Inject VizCraft default CSS + dark overrides once into <head>
@@ -90,19 +108,75 @@ function ensureVizCss() {
90
108
  }
91
109
 
92
110
  /**
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
111
+ * Replace typographic characters and structural issues that the YAML parser mishandles.
97
112
  *
98
- * These are commonly injected by LLMs or copy-paste from rich text.
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.
99
121
  */
100
122
  function sanitizeSpecText(raw: string): string {
101
- return raw
123
+ let s = raw
102
124
  .replace(/\u2013/g, "-") // en-dash –
103
125
  .replace(/\u2014/g, "-") // em-dash —
104
126
  .replace(/[\u201C\u201D]/g, '"') // curly double quotes " "
105
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");
106
180
  }
107
181
 
108
182
  function parseSpec(raw: string): VizSpec {
@@ -117,6 +191,8 @@ function parseSpec(raw: string): VizSpec {
117
191
  /**
118
192
  * Walk each VizNode in the (already-built) scene and inject a sensible
119
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.
120
196
  *
121
197
  * We read the shape from the built VizNode (which uses the internal NodeShape
122
198
  * discriminated union, e.g. {kind:'rect', w:120, h:40}), not from VizSpec.
@@ -124,12 +200,17 @@ function parseSpec(raw: string): VizSpec {
124
200
  function injectLabelMaxWidth(builder: ReturnType<typeof fromSpec>): void {
125
201
  const scene = builder.build();
126
202
  for (const node of scene.nodes) {
127
- if (
128
- !node.label ||
203
+ if (!node.label) continue;
204
+ const alreadyHasMaxWidth =
129
205
  (node.label as VizNode["label"] & { maxWidth?: number })?.maxWidth !==
130
- undefined
131
- )
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
+ }
132
212
  continue;
213
+ }
133
214
  const shape = node.shape as Record<string, unknown>;
134
215
  let w = 60;
135
216
  if (typeof shape.w === "number") w = shape.w;
@@ -145,6 +226,8 @@ function injectLabelMaxWidth(builder: ReturnType<typeof fromSpec>): void {
145
226
  (node.label as { overflow?: "visible" | "ellipsis" | "clip" })
146
227
  .overflow ?? "ellipsis",
147
228
  },
229
+ // Show full label on hover so truncated "..." nodes are readable
230
+ tooltip: node.tooltip ?? node.label.text,
148
231
  });
149
232
  }
150
233
  }
@@ -176,6 +259,7 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
176
259
  const [refineHistory, setRefineHistory] = useState<
177
260
  Array<{ prompt: string; spec: string }>
178
261
  >([]);
262
+ const [copiedSpec, setCopiedSpec] = useState(false);
179
263
 
180
264
  // Keep activeSpec in sync when the prop changes (streaming / message reload)
181
265
  useEffect(() => {
@@ -193,7 +277,19 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
193
277
  });
194
278
  if (!res.ok) throw new Error("Fix request failed");
195
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
+ }
196
290
  setActiveSpec(fixed);
291
+ // Persist the fix so it survives reload (mirrors handleRefine behaviour)
292
+ onSpecRefined?.(spec, fixed);
197
293
  } catch (err) {
198
294
  console.error("Fix viz error:", err);
199
295
  } finally {
@@ -236,6 +332,130 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
236
332
  setRefineHistory((h) => h.slice(0, -1));
237
333
  };
238
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
+
239
459
  // Prevent the parent chat scroll container from scrolling when the user
240
460
  // wheels over the viz. We need a non-passive listener so preventDefault works.
241
461
  // (React's synthetic onWheel is passive in some environments and can't prevent scroll.)
@@ -460,7 +680,7 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
460
680
  <div className="my-3 rounded-lg overflow-hidden border border-slate-700/50 bg-slate-900/60">
461
681
  {/* Toolbar — zoom controls (always shown unless there's an error) */}
462
682
  {!error && (
463
- <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">
464
684
  {/* Zoom out */}
465
685
  <button
466
686
  onClick={handleZoomOut}
@@ -488,6 +708,30 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
488
708
  <Maximize2 className="w-3 h-3" />
489
709
  Fit
490
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>
491
735
  </div>
492
736
  )}
493
737
 
@@ -532,7 +776,7 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
532
776
 
533
777
  {/* Step controls — only rendered when spec has steps */}
534
778
  {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">
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">
536
780
  {/* Prev */}
537
781
  <button
538
782
  onClick={handlePrev}
@@ -589,7 +833,7 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
589
833
  )}
590
834
 
591
835
  {/* Refine panel */}
592
- <div className="border-t border-slate-700/50">
836
+ <div className="relative z-10 border-t border-slate-700/50">
593
837
  {/* History chips */}
594
838
  {refineHistory.length > 0 && (
595
839
  <div className="flex flex-wrap items-center gap-1.5 px-3 pt-2">
@@ -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
  );
@@ -0,0 +1,124 @@
1
+ import type { InfraLabWorkspace } from "./types";
2
+
3
+ const DEFAULT_INFRA_FILES: Record<string, string> = {
4
+ "provider.tf": `terraform {
5
+ required_providers {
6
+ aws = {
7
+ source = "hashicorp/aws"
8
+ version = "~> 5.0"
9
+ }
10
+ }
11
+ }
12
+
13
+ provider "aws" {
14
+ region = "us-east-1"
15
+ access_key = "test"
16
+ secret_key = "test"
17
+ skip_credentials_validation = true
18
+ skip_metadata_api_check = true
19
+ skip_requesting_account_id = true
20
+ s3_use_path_style = true
21
+
22
+ endpoints {
23
+ s3 = "http://localhost:4566"
24
+ }
25
+ }
26
+ `,
27
+ "main.tf": `resource "aws_s3_bucket" "example" {
28
+ bucket = "practice-bucket"
29
+ }
30
+ `,
31
+ };
32
+
33
+ export const DEFAULT_INFRA_LAB: InfraLabWorkspace = {
34
+ version: 1,
35
+ label: "AWS LocalStack Lab",
36
+ provider: "aws",
37
+ executionMode: "localstack",
38
+ activeFile: "main.tf",
39
+ files: DEFAULT_INFRA_FILES,
40
+ };
41
+
42
+ export function cloneInfraLabWorkspace(
43
+ workspace?: InfraLabWorkspace | null,
44
+ ): InfraLabWorkspace {
45
+ const source = workspace ?? DEFAULT_INFRA_LAB;
46
+ // Only seed defaults when the source has no files of its own
47
+ const files =
48
+ source.files && Object.keys(source.files).length > 0
49
+ ? { ...source.files }
50
+ : { ...DEFAULT_INFRA_FILES };
51
+ const activeFile = files[source.activeFile]
52
+ ? source.activeFile
53
+ : (Object.keys(files)[0] ?? "main.tf");
54
+
55
+ return {
56
+ version: 1,
57
+ label: source.label || DEFAULT_INFRA_LAB.label,
58
+ provider: "aws",
59
+ executionMode:
60
+ source.executionMode === "plan-only" ? "plan-only" : "localstack",
61
+ activeFile,
62
+ files,
63
+ };
64
+ }
65
+
66
+ export function getInfraLabFileOrder(workspace: InfraLabWorkspace): string[] {
67
+ const preferred = [
68
+ "main.tf",
69
+ "provider.tf",
70
+ "variables.tf",
71
+ "terraform.tfvars",
72
+ "outputs.tf",
73
+ "locals.tf",
74
+ "README.md",
75
+ ];
76
+ const extras = Object.keys(workspace.files)
77
+ .filter((name) => !preferred.includes(name))
78
+ .sort();
79
+
80
+ return preferred.filter((name) => workspace.files[name]).concat(extras);
81
+ }
82
+
83
+ export function serializeInfraLabWorkspace(
84
+ workspace: InfraLabWorkspace,
85
+ ): string {
86
+ return JSON.stringify(cloneInfraLabWorkspace(workspace), null, 2);
87
+ }
88
+
89
+ export function parseInfraLabWorkspace(raw: string): InfraLabWorkspace | null {
90
+ try {
91
+ const parsed = JSON.parse(raw) as Partial<InfraLabWorkspace> & {
92
+ files?: Record<string, unknown>;
93
+ };
94
+
95
+ if (!parsed || typeof parsed !== "object") return null;
96
+ if (!parsed.files || typeof parsed.files !== "object") return null;
97
+
98
+ const files = Object.fromEntries(
99
+ Object.entries(parsed.files).filter(
100
+ (entry): entry is [string, string] => typeof entry[1] === "string",
101
+ ),
102
+ );
103
+
104
+ if (Object.keys(files).length === 0) return null;
105
+
106
+ return cloneInfraLabWorkspace({
107
+ version: 1,
108
+ label:
109
+ typeof parsed.label === "string" && parsed.label.trim()
110
+ ? parsed.label.trim()
111
+ : DEFAULT_INFRA_LAB.label,
112
+ provider: "aws",
113
+ executionMode:
114
+ parsed.executionMode === "plan-only" ? "plan-only" : "localstack",
115
+ activeFile:
116
+ typeof parsed.activeFile === "string"
117
+ ? parsed.activeFile
118
+ : DEFAULT_INFRA_LAB.activeFile,
119
+ files,
120
+ });
121
+ } catch {
122
+ return null;
123
+ }
124
+ }