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.
- package/package.json +1 -1
- package/template/client/package-lock.json +753 -1
- package/template/client/package.json +4 -0
- package/template/client/src/App.tsx +20 -0
- package/template/client/src/api.ts +455 -3
- package/template/client/src/components/AiSettingsModal.tsx +855 -248
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +132 -27
- package/template/client/src/components/ChatView.tsx +365 -123
- package/template/client/src/components/CodeContextPanel.tsx +714 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
- package/template/client/src/components/DocRefModal.tsx +551 -0
- package/template/client/src/components/FileAttachments.tsx +128 -12
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +219 -2
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +397 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +412 -25
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +477 -0
- package/template/client/src/store.ts +416 -2
- package/template/client/src/types.ts +41 -1
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +144 -2
- package/template/server/src/index.ts +1890 -188
- package/template/server/src/infra-runner.ts +1104 -0
- 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
|
-
|
|
203
|
+
if (!node.label) continue;
|
|
204
|
+
const alreadyHasMaxWidth =
|
|
110
205
|
(node.label as VizNode["label"] & { maxWidth?: number })?.maxWidth !==
|
|
111
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
);
|