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.
- package/README.md +23 -0
- package/package.json +1 -1
- package/template/client/package-lock.json +42 -0
- package/template/client/package.json +5 -0
- package/template/client/src/App.tsx +45 -12
- package/template/client/src/api.ts +174 -0
- package/template/client/src/components/AiSettingsModal.tsx +1041 -0
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +110 -27
- package/template/client/src/components/ChatView.tsx +239 -137
- package/template/client/src/components/CodeContextPanel.tsx +297 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
- package/template/client/src/components/DocRefModal.tsx +502 -0
- package/template/client/src/components/FileAttachments.tsx +109 -9
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/MarkdownRenderer.tsx +210 -2
- package/template/client/src/components/Sidebar.tsx +213 -125
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +645 -0
- package/template/client/src/store.ts +275 -0
- package/template/client/src/types.ts +9 -0
- package/template/cockpit.json +1 -1
- package/template/data/ai-settings.json +49 -0
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1187 -76
- 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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|