create-interview-cockpit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +19 -0
  3. package/template/client/package.json +3 -0
  4. package/template/client/src/App.tsx +17 -0
  5. package/template/client/src/api.ts +135 -0
  6. package/template/client/src/components/AiSettingsModal.tsx +218 -4
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +110 -27
  9. package/template/client/src/components/ChatView.tsx +69 -4
  10. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  13. package/template/client/src/components/DocRefModal.tsx +502 -0
  14. package/template/client/src/components/FileAttachments.tsx +109 -9
  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/MarkdownRenderer.tsx +205 -2
  18. package/template/client/src/components/Sidebar.tsx +213 -127
  19. package/template/client/src/components/TextAnnotator.tsx +8 -15
  20. package/template/client/src/components/VizCraftEmbed.tsx +162 -19
  21. package/template/client/src/store.ts +201 -0
  22. package/template/client/src/types.ts +8 -0
  23. package/template/cockpit.json +1 -1
  24. package/template/package.json +1 -1
  25. package/template/server/src/google-drive.ts +109 -1
  26. package/template/server/src/index.ts +1107 -46
  27. package/template/server/src/storage.ts +263 -2
@@ -0,0 +1,1549 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ useLayoutEffect,
7
+ } from "react";
8
+ import {
9
+ X,
10
+ GripVertical,
11
+ Maximize2,
12
+ Minimize2,
13
+ Play,
14
+ Trash2,
15
+ Loader2,
16
+ Terminal,
17
+ Clock,
18
+ Save,
19
+ Check,
20
+ Server,
21
+ StopCircle,
22
+ Globe,
23
+ Copy,
24
+ ChevronLeft,
25
+ ChevronRight,
26
+ ChevronUp,
27
+ ChevronDown,
28
+ } from "lucide-react";
29
+ import { useStore } from "../store";
30
+ import Editor from "react-simple-code-editor";
31
+ import Prism from "prismjs";
32
+ import "prismjs/components/prism-clike";
33
+ import "prismjs/components/prism-javascript";
34
+ import "prismjs/components/prism-typescript";
35
+
36
+ const MIN_W = 420;
37
+ const MIN_H = 300;
38
+ const DEFAULT_W = 760;
39
+ const DEFAULT_H = 560;
40
+
41
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
42
+
43
+ interface OutputLine {
44
+ kind: "stdout" | "stderr" | "info" | "warn";
45
+ text: string;
46
+ source?: "server" | "client";
47
+ }
48
+
49
+ const LANG_OPTIONS = ["typescript", "javascript"] as const;
50
+ type Lang = (typeof LANG_OPTIONS)[number];
51
+
52
+ // ── Sandbox default snippets ─────────────────────────────────────────
53
+ const DEFAULT_SERVER_CODE = `import express from 'express';
54
+ import { Readable } from 'stream';
55
+ const app = express();
56
+ app.use(express.json());
57
+
58
+ app.get('/ping', (_req, res) => {
59
+ res.json({ message: 'pong', time: Date.now() });
60
+ });
61
+
62
+ const port = Number(process.env.PORT);
63
+ app.listen(port, () => console.log('Server on :' + port));
64
+ `;
65
+
66
+ const DEFAULT_CLIENT_CODE = `// SANDBOX_URL is pre-injected \u2014 points at your running server
67
+
68
+ // \u2500\u2500 REST \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
69
+ console.log('--- REST ---');
70
+ const rest = await fetch(SANDBOX_URL + '/ping');
71
+ console.log(await rest.json());
72
+ `;
73
+
74
+ // VS Code Dark+ token colours injected once for prismjs.
75
+ // react-simple-code-editor uses Prism.highlight() which emits class-based spans;
76
+ // these inline styles map those classes to the same palette used in the old theme.
77
+ function ensurePrismStyles() {
78
+ const ID = "__code_runner_prism_vsc";
79
+ if (typeof document === "undefined" || document.getElementById(ID)) return;
80
+ const el = document.createElement("style");
81
+ el.id = ID;
82
+ el.textContent = [
83
+ `.token.comment,.token.prolog,.token.doctype,.token.cdata{color:#6A9955}`,
84
+ `.token.punctuation{color:#D4D4D4}`,
85
+ `.token.keyword{color:#569CD6}`,
86
+ `.token.builtin{color:#4EC9B0}`,
87
+ `.token.boolean,.token.constant{color:#569CD6}`,
88
+ `.token.number{color:#B5CEA8}`,
89
+ `.token.string,.token.attr-value,.token.template-string{color:#CE9178}`,
90
+ `.token.char,.token.regex{color:#D16969}`,
91
+ `.token.operator,.token.entity,.token.url{color:#D4D4D4}`,
92
+ `.token.variable{color:#9CDCFE}`,
93
+ `.token.atrule,.token.attr-name{color:#9CDCFE}`,
94
+ `.token.function{color:#DCDCAA}`,
95
+ `.token.class-name{color:#4EC9B0}`,
96
+ `.token.property{color:#9CDCFE}`,
97
+ `.token.selector{color:#D7BA7D}`,
98
+ `.token.tag{color:#4EC9B0}`,
99
+ `.token.important,.token.bold{font-weight:bold}`,
100
+ `.token.italic{font-style:italic}`,
101
+ ].join("\n");
102
+ document.head.appendChild(el);
103
+ }
104
+
105
+ const EDITOR_FONT =
106
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
107
+
108
+ // Syntax-highlighted code editor using react-simple-code-editor.
109
+ // Cursor alignment is exact because the library manages both the textarea and
110
+ // the highlight <pre> with identical computed styles internally.
111
+ function SyntaxEditor({
112
+ value,
113
+ onChange,
114
+ onCtrlEnter,
115
+ language,
116
+ placeholder,
117
+ autoFocus = false,
118
+ fontSize = "12px",
119
+ focusRingClass = "ring-violet-500/40",
120
+ }: {
121
+ value: string;
122
+ onChange: (val: string) => void;
123
+ onCtrlEnter?: () => void;
124
+ language: string;
125
+ placeholder?: string;
126
+ autoFocus?: boolean;
127
+ fontSize?: string;
128
+ focusRingClass?: string;
129
+ }) {
130
+ const containerRef = useRef<HTMLDivElement>(null);
131
+
132
+ useEffect(() => {
133
+ ensurePrismStyles();
134
+ if (autoFocus) {
135
+ setTimeout(
136
+ () =>
137
+ containerRef.current
138
+ ?.querySelector<HTMLTextAreaElement>("textarea")
139
+ ?.focus(),
140
+ 50,
141
+ );
142
+ }
143
+ // eslint-disable-next-line react-hooks/exhaustive-deps
144
+ }, []);
145
+
146
+ const grammar =
147
+ language === "typescript"
148
+ ? Prism.languages.typescript
149
+ : Prism.languages.javascript;
150
+
151
+ return (
152
+ <div
153
+ ref={containerRef}
154
+ className="absolute inset-0 overflow-auto bg-slate-950"
155
+ >
156
+ <Editor
157
+ value={value}
158
+ onValueChange={onChange}
159
+ highlight={(src) =>
160
+ grammar ? Prism.highlight(src, grammar, language) : src
161
+ }
162
+ padding={12}
163
+ insertSpaces
164
+ tabSize={2}
165
+ placeholder={placeholder}
166
+ onKeyDown={(e: React.KeyboardEvent) => {
167
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
168
+ e.preventDefault();
169
+ onCtrlEnter?.();
170
+ }
171
+ }}
172
+ style={{
173
+ fontFamily: EDITOR_FONT,
174
+ fontSize,
175
+ lineHeight: 1.625,
176
+ color: "#D4D4D4",
177
+ caretColor: "white",
178
+ minHeight: "100%",
179
+ }}
180
+ textareaClassName={`outline-none focus:ring-1 ${focusRingClass} placeholder:text-slate-600`}
181
+ />
182
+ </div>
183
+ );
184
+ }
185
+
186
+ export default function CodeRunnerModal() {
187
+ const {
188
+ closeCodeRunner,
189
+ runnerInitialCode,
190
+ runnerInitialLanguage,
191
+ runnerInitialSandbox,
192
+ currentQuestion,
193
+ saveCodeSnippetToQuestion,
194
+ overwriteContextFileContent,
195
+ } = useStore();
196
+
197
+ const [code, setCode] = useState(runnerInitialCode);
198
+ const [lang, setLang] = useState<Lang>(
199
+ (runnerInitialLanguage as Lang) ?? "typescript",
200
+ );
201
+ const [running, setRunning] = useState(false);
202
+ const [output, setOutput] = useState<OutputLine[]>([]);
203
+ const [durationMs, setDurationMs] = useState<number | null>(null);
204
+ const [timedOut, setTimedOut] = useState(false);
205
+ const [saved, setSaved] = useState(false);
206
+ const [saving, setSaving] = useState(false);
207
+ const [naming, setNaming] = useState(false);
208
+ const [snippetName, setSnippetName] = useState("");
209
+
210
+ // ── Sandbox state ─────────────────────────────────────────
211
+ const [mode, setMode] = useState<"script" | "sandbox">("script");
212
+ const [serverCode, setServerCode] = useState(DEFAULT_SERVER_CODE);
213
+ const [serverLang, setServerLang] = useState<Lang>("typescript");
214
+ const [clientCode, setClientCode] = useState(DEFAULT_CLIENT_CODE);
215
+ const [clientLang, setClientLang] = useState<Lang>("javascript");
216
+ const [sandboxId, setSandboxId] = useState<string | null>(null);
217
+ const [sandboxPort, setSandboxPort] = useState<number | null>(null);
218
+ const [sandboxUrl, setSandboxUrl] = useState<string | null>(null);
219
+ const [serverStarting, setServerStarting] = useState(false);
220
+ const [serverRunning, setServerRunning] = useState(false);
221
+ const [sandboxOutput, setSandboxOutput] = useState<OutputLine[]>([]);
222
+ const [clientRunning, setClientRunning] = useState(false);
223
+
224
+ // ── Sandbox panel sizes ─────────────────────────────────────────
225
+ // sbxSplit: server pane width as % of the editor row (0–100)
226
+ const [sbxSplit, setSbxSplit] = useState(50);
227
+ // sbxOutputH: output panel height in px
228
+ const [sbxOutputH, setSbxOutputH] = useState(176);
229
+ const [serverCollapsed, setServerCollapsed] = useState(false);
230
+ const [clientCollapsed, setClientCollapsed] = useState(false);
231
+ const [outputCollapsed, setOutputCollapsed] = useState(false);
232
+ const sbxDividerDrag = useRef<{
233
+ startX: number;
234
+ startPct: number;
235
+ containerW: number;
236
+ } | null>(null);
237
+ const sbxOutputDrag = useRef<{ startY: number; startH: number } | null>(null);
238
+ const sbxEditorRowRef = useRef<HTMLDivElement>(null);
239
+
240
+ // ── Sandbox save state (single combined save) ──────────────────
241
+ const [sbxNaming, setSbxNaming] = useState(false);
242
+ const [sbxName, setSbxName] = useState("My Sandbox");
243
+ const [sbxSaved, setSbxSaved] = useState(false);
244
+ const [sbxSaving, setSbxSaving] = useState(false);
245
+ /** When non-null we are editing a previously saved sandbox — Save overwrites, Save As still creates new */
246
+ const [activeSandboxId, setActiveSandboxId] = useState<string | null>(null);
247
+ const sbxNameInputRef = useRef<HTMLInputElement>(null);
248
+
249
+ // Save server+client together as one JSON blob with origin 'sandbox'
250
+ const saveSandboxSnippet = async (label: string) => {
251
+ if (!currentQuestion) return;
252
+ setSbxSaving(true);
253
+ try {
254
+ const payload = JSON.stringify({
255
+ serverCode,
256
+ serverLang,
257
+ clientCode,
258
+ clientLang,
259
+ });
260
+ const cf = await saveCodeSnippetToQuestion(
261
+ currentQuestion.id,
262
+ payload,
263
+ "sandbox",
264
+ label || "My Sandbox",
265
+ "sandbox",
266
+ );
267
+ setActiveSandboxId(cf.id);
268
+ setSbxSaved(true);
269
+ setTimeout(() => setSbxSaved(false), 2000);
270
+ } finally {
271
+ setSbxSaving(false);
272
+ }
273
+ };
274
+
275
+ // Overwrite the existing sandbox blob in-place
276
+ const overwriteSandboxSnippet = async () => {
277
+ if (!currentQuestion || !activeSandboxId) return;
278
+ setSbxSaving(true);
279
+ try {
280
+ const payload = JSON.stringify({
281
+ serverCode,
282
+ serverLang,
283
+ clientCode,
284
+ clientLang,
285
+ });
286
+ await overwriteContextFileContent(
287
+ currentQuestion.id,
288
+ activeSandboxId,
289
+ payload,
290
+ );
291
+ setSbxSaved(true);
292
+ setTimeout(() => setSbxSaved(false), 2000);
293
+ } finally {
294
+ setSbxSaving(false);
295
+ }
296
+ };
297
+
298
+ const outputEndRef = useRef<HTMLDivElement>(null);
299
+ const nameInputRef = useRef<HTMLInputElement>(null);
300
+ // Tracks how many server log lines have already been flushed to sandboxOutput
301
+ const sandboxLogOffsetRef = useRef(0);
302
+ // Stable ref so unmount cleanup can stop sandbox without stale closure
303
+ const sandboxIdRef = useRef<string | null>(null);
304
+ useEffect(() => {
305
+ sandboxIdRef.current = sandboxId;
306
+ }, [sandboxId]);
307
+
308
+ // Sync initial code when the modal is (re)opened with new content
309
+ useLayoutEffect(() => {
310
+ setCode(runnerInitialCode);
311
+ setLang((runnerInitialLanguage as Lang) ?? "typescript");
312
+ setOutput([]);
313
+ setDurationMs(null);
314
+ setTimedOut(false);
315
+ }, [runnerInitialCode, runnerInitialLanguage]);
316
+
317
+ // Restore a saved sandbox when opened via openSandbox()
318
+ useLayoutEffect(() => {
319
+ if (!runnerInitialSandbox) return;
320
+ setMode("sandbox");
321
+ setServerCode(runnerInitialSandbox.serverCode);
322
+ setServerLang((runnerInitialSandbox.serverLang as Lang) ?? "typescript");
323
+ setClientCode(runnerInitialSandbox.clientCode);
324
+ setClientLang((runnerInitialSandbox.clientLang as Lang) ?? "javascript");
325
+ setSandboxOutput([]);
326
+ setActiveSandboxId(runnerInitialSandbox.fileId ?? null);
327
+ }, [runnerInitialSandbox]);
328
+
329
+ // Auto-focus is handled inside SyntaxEditor via autoFocus prop.
330
+
331
+ // Scroll output to bottom whenever it grows
332
+ useEffect(() => {
333
+ outputEndRef.current?.scrollIntoView({ behavior: "smooth" });
334
+ }, [output]);
335
+
336
+ // ── Sandbox divider drag handlers ────────────────────────────────
337
+ useEffect(() => {
338
+ const onMove = (e: MouseEvent) => {
339
+ if (sbxDividerDrag.current) {
340
+ const { startX, startPct, containerW } = sbxDividerDrag.current;
341
+ const dx = e.clientX - startX;
342
+ const pct = Math.min(
343
+ 95,
344
+ Math.max(5, startPct + (dx / containerW) * 100),
345
+ );
346
+ setSbxSplit(pct);
347
+ }
348
+ if (sbxOutputDrag.current) {
349
+ const { startY, startH } = sbxOutputDrag.current;
350
+ const dy = startY - e.clientY; // drag up = increase height
351
+ setSbxOutputH(Math.max(60, Math.min(600, startH + dy)));
352
+ }
353
+ };
354
+ const onUp = () => {
355
+ sbxDividerDrag.current = null;
356
+ sbxOutputDrag.current = null;
357
+ };
358
+ document.addEventListener("mousemove", onMove);
359
+ document.addEventListener("mouseup", onUp);
360
+ return () => {
361
+ document.removeEventListener("mousemove", onMove);
362
+ document.removeEventListener("mouseup", onUp);
363
+ };
364
+ }, []);
365
+
366
+ // ── Position / Size ──────────────────────────────────────────
367
+
368
+ const [pos, setPos] = useState(() => ({
369
+ x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
370
+ y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
371
+ }));
372
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
373
+ const [maximized, setMaximized] = useState(false);
374
+ const savedPos = useRef(pos);
375
+ const savedSize = useRef(size);
376
+
377
+ const dragStart = useRef<{
378
+ mx: number;
379
+ my: number;
380
+ ox: number;
381
+ oy: number;
382
+ } | null>(null);
383
+ const resizeDir = useRef<ResizeDir>(null);
384
+ const resizeStart = useRef<{
385
+ mx: number;
386
+ my: number;
387
+ ox: number;
388
+ oy: number;
389
+ ow: number;
390
+ oh: number;
391
+ } | null>(null);
392
+
393
+ useEffect(() => {
394
+ const onMove = (e: MouseEvent) => {
395
+ const drag = dragStart.current;
396
+ const resize = resizeStart.current;
397
+ const dir = resizeDir.current;
398
+ if (drag) {
399
+ setPos({
400
+ x: Math.max(0, drag.ox + (e.clientX - drag.mx)),
401
+ y: Math.max(0, drag.oy + (e.clientY - drag.my)),
402
+ });
403
+ }
404
+ if (resize && dir) {
405
+ const dx = e.clientX - resize.mx;
406
+ const dy = e.clientY - resize.my;
407
+ setSize((prev) => {
408
+ let w = prev.w;
409
+ let h = prev.h;
410
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
411
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
412
+ if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
413
+ if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
414
+ return { w, h };
415
+ });
416
+ if (dir.includes("w"))
417
+ setPos((p) => ({
418
+ ...p,
419
+ x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
420
+ }));
421
+ if (dir.includes("n"))
422
+ setPos((p) => ({
423
+ ...p,
424
+ y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
425
+ }));
426
+ }
427
+ };
428
+ const onUp = () => {
429
+ dragStart.current = null;
430
+ resizeStart.current = null;
431
+ resizeDir.current = null;
432
+ };
433
+ document.addEventListener("mousemove", onMove);
434
+ document.addEventListener("mouseup", onUp);
435
+ return () => {
436
+ document.removeEventListener("mousemove", onMove);
437
+ document.removeEventListener("mouseup", onUp);
438
+ };
439
+ }, []);
440
+
441
+ const onTitleMouseDown = useCallback(
442
+ (e: React.MouseEvent) => {
443
+ if (maximized) return;
444
+ e.preventDefault();
445
+ dragStart.current = {
446
+ mx: e.clientX,
447
+ my: e.clientY,
448
+ ox: pos.x,
449
+ oy: pos.y,
450
+ };
451
+ },
452
+ [maximized, pos],
453
+ );
454
+
455
+ const startResize = useCallback(
456
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
457
+ if (maximized) return;
458
+ e.preventDefault();
459
+ e.stopPropagation();
460
+ resizeDir.current = dir;
461
+ resizeStart.current = {
462
+ mx: e.clientX,
463
+ my: e.clientY,
464
+ ox: pos.x,
465
+ oy: pos.y,
466
+ ow: size.w,
467
+ oh: size.h,
468
+ };
469
+ },
470
+ [maximized, pos, size],
471
+ );
472
+
473
+ const toggleMax = useCallback(() => {
474
+ if (!maximized) {
475
+ savedPos.current = pos;
476
+ savedSize.current = size;
477
+ setMaximized(true);
478
+ } else {
479
+ setPos(savedPos.current);
480
+ setSize(savedSize.current);
481
+ setMaximized(false);
482
+ }
483
+ }, [maximized, pos, size]);
484
+
485
+ useEffect(() => {
486
+ const handler = (e: KeyboardEvent) => {
487
+ if (e.key === "Escape") closeCodeRunner();
488
+ };
489
+ document.addEventListener("keydown", handler);
490
+ return () => document.removeEventListener("keydown", handler);
491
+ }, [closeCodeRunner]);
492
+
493
+ // ── Run ──────────────────────────────────────────────────────
494
+
495
+ const runCode = useCallback(async () => {
496
+ if (running) return;
497
+ setRunning(true);
498
+ setOutput([]);
499
+ setDurationMs(null);
500
+ setTimedOut(false);
501
+
502
+ try {
503
+ const res = await fetch("/api/run-code", {
504
+ method: "POST",
505
+ headers: { "Content-Type": "application/json" },
506
+ body: JSON.stringify({ code, language: lang }),
507
+ });
508
+ const data: {
509
+ stdout: string;
510
+ stderr: string;
511
+ durationMs: number;
512
+ timedOut: boolean;
513
+ } = await res.json();
514
+
515
+ const lines: OutputLine[] = [];
516
+
517
+ if (data.stdout.trim()) {
518
+ data.stdout.split("\n").forEach((l) => {
519
+ if (l !== "") lines.push({ kind: "stdout", text: l });
520
+ });
521
+ }
522
+ if (data.stderr.trim()) {
523
+ data.stderr.split("\n").forEach((l) => {
524
+ if (l !== "") lines.push({ kind: "stderr", text: l });
525
+ });
526
+ }
527
+ if (lines.length === 0) {
528
+ lines.push({ kind: "info", text: "(no output)" });
529
+ }
530
+ if (data.timedOut) {
531
+ lines.push({ kind: "warn", text: "⏱ Timed out after 10 seconds" });
532
+ }
533
+
534
+ setOutput(lines);
535
+ setDurationMs(data.durationMs);
536
+ setTimedOut(data.timedOut);
537
+ } catch (err) {
538
+ setOutput([{ kind: "stderr", text: String(err) }]);
539
+ } finally {
540
+ setRunning(false);
541
+ }
542
+ }, [code, lang, running]);
543
+
544
+ // ── Sandbox: stop on unmount ──────────────────────────────
545
+
546
+ useEffect(() => {
547
+ return () => {
548
+ if (sandboxIdRef.current) {
549
+ fetch(`/api/sandbox/${sandboxIdRef.current}`, {
550
+ method: "DELETE",
551
+ }).catch(() => {});
552
+ }
553
+ };
554
+ }, []);
555
+
556
+ // ── Sandbox: poll server logs while running ────────────────
557
+
558
+ useEffect(() => {
559
+ if (!sandboxId) return;
560
+ const interval = setInterval(async () => {
561
+ try {
562
+ const r = await fetch(`/api/sandbox/${sandboxId}/status`);
563
+ const data = await r.json();
564
+ if (!data.running) {
565
+ setSandboxId(null);
566
+ setSandboxPort(null);
567
+ setSandboxUrl(null);
568
+ setServerRunning(false);
569
+ sandboxLogOffsetRef.current = 0;
570
+ return;
571
+ }
572
+ const newLogs: string[] = (data.logs as string[]).slice(
573
+ sandboxLogOffsetRef.current,
574
+ );
575
+ if (newLogs.length > 0) {
576
+ sandboxLogOffsetRef.current = (data.logs as string[]).length;
577
+ setSandboxOutput((prev) => [
578
+ ...prev,
579
+ ...newLogs.flatMap((chunk) =>
580
+ chunk
581
+ .split("\n")
582
+ .filter(Boolean)
583
+ .map((text) => ({
584
+ kind: "stdout" as const,
585
+ text,
586
+ source: "server" as const,
587
+ })),
588
+ ),
589
+ ]);
590
+ }
591
+ } catch {
592
+ /* ignore transient network errors */
593
+ }
594
+ }, 1000);
595
+ return () => clearInterval(interval);
596
+ }, [sandboxId]);
597
+
598
+ // ── Sandbox handlers ─────────────────────────────────────
599
+
600
+ const startServer = async () => {
601
+ if (serverStarting) return;
602
+ // Tear down any existing sandbox first
603
+ if (sandboxId) {
604
+ await fetch(`/api/sandbox/${sandboxId}`, { method: "DELETE" }).catch(
605
+ () => {},
606
+ );
607
+ setSandboxId(null);
608
+ setSandboxPort(null);
609
+ setSandboxUrl(null);
610
+ setServerRunning(false);
611
+ }
612
+ setServerStarting(true);
613
+ sandboxLogOffsetRef.current = 0;
614
+ setSandboxOutput([{ kind: "info", text: "Starting server…" }]);
615
+ try {
616
+ const res = await fetch("/api/sandbox/start", {
617
+ method: "POST",
618
+ headers: { "Content-Type": "application/json" },
619
+ body: JSON.stringify({ serverCode, language: serverLang }),
620
+ });
621
+ const data = await res.json();
622
+ if (!res.ok) {
623
+ setSandboxOutput((prev) => [
624
+ ...prev,
625
+ { kind: "stderr", text: data.error || "Failed to start" },
626
+ ]);
627
+ return;
628
+ }
629
+ setSandboxId(data.sandboxId);
630
+ setSandboxPort(data.port);
631
+ setSandboxUrl(data.sandboxUrl);
632
+ setServerRunning(true);
633
+ setSandboxOutput((prev) => [
634
+ ...prev,
635
+ {
636
+ kind: "info",
637
+ text: `✓ Server started at ${data.sandboxUrl}`,
638
+ source: "server",
639
+ },
640
+ ]);
641
+ } catch (err) {
642
+ setSandboxOutput((prev) => [
643
+ ...prev,
644
+ { kind: "stderr", text: String(err) },
645
+ ]);
646
+ } finally {
647
+ setServerStarting(false);
648
+ }
649
+ };
650
+
651
+ const stopServer = async () => {
652
+ if (!sandboxId) return;
653
+ await fetch(`/api/sandbox/${sandboxId}`, { method: "DELETE" }).catch(
654
+ () => {},
655
+ );
656
+ setSandboxId(null);
657
+ setSandboxPort(null);
658
+ setSandboxUrl(null);
659
+ setServerRunning(false);
660
+ setSandboxOutput((prev) => [
661
+ ...prev,
662
+ { kind: "info", text: "Server stopped." },
663
+ ]);
664
+ };
665
+
666
+ const runClient = async () => {
667
+ if (clientRunning || !sandboxUrl) return;
668
+ setClientRunning(true);
669
+ setSandboxOutput((prev) => [
670
+ ...prev,
671
+ { kind: "info", text: "── client run ──", source: "client" },
672
+ ]);
673
+ let hadOutput = false;
674
+ try {
675
+ const res = await fetch("/api/sandbox/run-client", {
676
+ method: "POST",
677
+ headers: { "Content-Type": "application/json" },
678
+ body: JSON.stringify({
679
+ code: clientCode,
680
+ language: clientLang,
681
+ sandboxUrl,
682
+ }),
683
+ });
684
+ if (!res.body) throw new Error("No response body");
685
+ const reader = res.body.getReader();
686
+ const decoder = new TextDecoder();
687
+ let buf = "";
688
+ // Read the SSE stream and push each line to sandboxOutput immediately
689
+ while (true) {
690
+ const { done, value } = await reader.read();
691
+ if (done) break;
692
+ buf += decoder.decode(value, { stream: true });
693
+ // SSE messages are separated by double newlines
694
+ const parts = buf.split("\n\n");
695
+ buf = parts.pop() ?? "";
696
+ for (const part of parts) {
697
+ const dataLine = part.split("\n").find((l) => l.startsWith("data:"));
698
+ if (!dataLine) continue;
699
+ const payload = JSON.parse(dataLine.slice(5).trim()) as {
700
+ kind: string;
701
+ text: string;
702
+ };
703
+ if (payload.kind === "done") {
704
+ const meta = JSON.parse(payload.text) as {
705
+ timedOut: boolean;
706
+ durationMs: number;
707
+ };
708
+ if (!hadOutput)
709
+ setSandboxOutput((prev) => [
710
+ ...prev,
711
+ { kind: "info", text: "(no output)", source: "client" },
712
+ ]);
713
+ if (meta.timedOut)
714
+ setSandboxOutput((prev) => [
715
+ ...prev,
716
+ {
717
+ kind: "warn",
718
+ text: "⏱ Timed out after 10 seconds",
719
+ source: "client",
720
+ },
721
+ ]);
722
+ } else {
723
+ hadOutput = true;
724
+ setSandboxOutput((prev) => [
725
+ ...prev,
726
+ {
727
+ kind: payload.kind as OutputLine["kind"],
728
+ text: payload.text,
729
+ source: "client",
730
+ },
731
+ ]);
732
+ }
733
+ }
734
+ }
735
+ } catch (err) {
736
+ setSandboxOutput((prev) => [
737
+ ...prev,
738
+ { kind: "stderr", text: String(err), source: "client" },
739
+ ]);
740
+ } finally {
741
+ setClientRunning(false);
742
+ }
743
+ };
744
+
745
+ // ── Styles ───────────────────────────────────────────────────
746
+
747
+ const modalStyle: React.CSSProperties = maximized
748
+ ? {
749
+ position: "fixed",
750
+ inset: 0,
751
+ width: "100vw",
752
+ height: "100vh",
753
+ borderRadius: 0,
754
+ }
755
+ : {
756
+ position: "fixed",
757
+ left: pos.x,
758
+ top: pos.y,
759
+ width: size.w,
760
+ height: size.h,
761
+ minWidth: MIN_W,
762
+ minHeight: MIN_H,
763
+ };
764
+
765
+ return (
766
+ <div
767
+ className="fixed z-[60] flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
768
+ style={modalStyle}
769
+ >
770
+ {/* ── Resize handles ── */}
771
+ {!maximized && (
772
+ <>
773
+ <div
774
+ className="absolute inset-x-0 top-0 h-1 cursor-n-resize"
775
+ onMouseDown={startResize("n")}
776
+ />
777
+ <div
778
+ className="absolute inset-x-0 bottom-0 h-1 cursor-s-resize"
779
+ onMouseDown={startResize("s")}
780
+ />
781
+ <div
782
+ className="absolute inset-y-0 left-0 w-1 cursor-w-resize"
783
+ onMouseDown={startResize("w")}
784
+ />
785
+ <div
786
+ className="absolute inset-y-0 right-0 w-1 cursor-e-resize"
787
+ onMouseDown={startResize("e")}
788
+ />
789
+ <div
790
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize"
791
+ onMouseDown={startResize("se")}
792
+ />
793
+ <div
794
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize"
795
+ onMouseDown={startResize("sw")}
796
+ />
797
+ </>
798
+ )}
799
+
800
+ {/* ── Title bar ── */}
801
+ <div
802
+ className="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700 shrink-0"
803
+ onMouseDown={onTitleMouseDown}
804
+ style={{ cursor: maximized ? "default" : "grab" }}
805
+ >
806
+ <GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
807
+ <Terminal className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
808
+ <span className="text-xs font-mono text-slate-300">Code Runner</span>
809
+
810
+ {/* Mode toggle */}
811
+ <div
812
+ className="flex items-center rounded overflow-hidden border border-slate-700 text-[10px] ml-1 mr-auto shrink-0"
813
+ onMouseDown={(e) => e.stopPropagation()}
814
+ >
815
+ {(["script", "sandbox"] as const).map((m) => (
816
+ <button
817
+ key={m}
818
+ type="button"
819
+ onClick={() => setMode(m)}
820
+ className={`px-2 py-0.5 capitalize transition-colors ${
821
+ mode === m
822
+ ? "bg-slate-600 text-slate-200"
823
+ : "text-slate-500 hover:text-slate-400"
824
+ }`}
825
+ >
826
+ {m}
827
+ </button>
828
+ ))}
829
+ </div>
830
+
831
+ {/* Script-mode controls */}
832
+ {mode === "script" && (
833
+ <>
834
+ {/* Language toggle */}
835
+ <div className="flex items-center gap-0.5 shrink-0">
836
+ {LANG_OPTIONS.map((l) => (
837
+ <button
838
+ key={l}
839
+ type="button"
840
+ onMouseDown={(e) => e.stopPropagation()}
841
+ onClick={() => setLang(l)}
842
+ className={`px-2 py-0.5 rounded text-[10px] uppercase tracking-wider font-mono transition-colors ${
843
+ lang === l
844
+ ? "bg-violet-600/30 text-violet-300"
845
+ : "text-slate-500 hover:text-slate-300"
846
+ }`}
847
+ >
848
+ {l === "typescript" ? "TS" : "JS"}
849
+ </button>
850
+ ))}
851
+ </div>
852
+
853
+ {/* Save to Context */}
854
+ {currentQuestion && !naming && (
855
+ <button
856
+ type="button"
857
+ onMouseDown={(e) => e.stopPropagation()}
858
+ onClick={() => {
859
+ if (!code.trim()) return;
860
+ setSnippetName("Runner snippet");
861
+ setNaming(true);
862
+ setTimeout(() => {
863
+ nameInputRef.current?.focus();
864
+ nameInputRef.current?.select();
865
+ }, 30);
866
+ }}
867
+ disabled={!code.trim()}
868
+ className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-colors shrink-0 ${
869
+ saved
870
+ ? "bg-cyan-600/30 text-cyan-300"
871
+ : "bg-slate-700/60 hover:bg-slate-600/60 text-slate-400 hover:text-slate-200"
872
+ } disabled:opacity-40`}
873
+ title="Save to question context"
874
+ >
875
+ {saved ? (
876
+ <>
877
+ <Check className="w-3 h-3" />
878
+ Saved
879
+ </>
880
+ ) : (
881
+ <>
882
+ <Save className="w-3 h-3" />
883
+ Save
884
+ </>
885
+ )}
886
+ </button>
887
+ )}
888
+
889
+ {/* Inline name input */}
890
+ {currentQuestion && naming && (
891
+ <div
892
+ className="flex items-center gap-1 shrink-0"
893
+ onMouseDown={(e) => e.stopPropagation()}
894
+ >
895
+ <input
896
+ ref={nameInputRef}
897
+ value={snippetName}
898
+ onChange={(e) => setSnippetName(e.target.value)}
899
+ onKeyDown={async (e) => {
900
+ if (e.key === "Enter") {
901
+ e.preventDefault();
902
+ const label = snippetName.trim() || "Runner snippet";
903
+ setSaving(true);
904
+ setNaming(false);
905
+ try {
906
+ await saveCodeSnippetToQuestion(
907
+ currentQuestion.id,
908
+ code,
909
+ lang,
910
+ label,
911
+ "user",
912
+ );
913
+ setSaved(true);
914
+ setTimeout(() => setSaved(false), 2000);
915
+ } finally {
916
+ setSaving(false);
917
+ }
918
+ } else if (e.key === "Escape") {
919
+ setNaming(false);
920
+ }
921
+ }}
922
+ placeholder="Snippet name…"
923
+ className="w-32 bg-slate-900 border border-cyan-600/50 rounded px-2 py-0.5 text-[11px] text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500"
924
+ />
925
+ <button
926
+ type="button"
927
+ onClick={async () => {
928
+ const label = snippetName.trim() || "Runner snippet";
929
+ setSaving(true);
930
+ setNaming(false);
931
+ try {
932
+ await saveCodeSnippetToQuestion(
933
+ currentQuestion.id,
934
+ code,
935
+ lang,
936
+ label,
937
+ "user",
938
+ );
939
+ setSaved(true);
940
+ setTimeout(() => setSaved(false), 2000);
941
+ } finally {
942
+ setSaving(false);
943
+ }
944
+ }}
945
+ className="p-0.5 rounded bg-cyan-600/20 hover:bg-cyan-600/40 text-cyan-400 transition-colors"
946
+ title="Confirm save (Enter)"
947
+ >
948
+ {saving ? (
949
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
950
+ ) : (
951
+ <Check className="w-3.5 h-3.5" />
952
+ )}
953
+ </button>
954
+ <button
955
+ type="button"
956
+ onClick={() => setNaming(false)}
957
+ className="p-0.5 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors"
958
+ title="Cancel (Esc)"
959
+ >
960
+ <X className="w-3.5 h-3.5" />
961
+ </button>
962
+ </div>
963
+ )}
964
+
965
+ {/* Run */}
966
+ <button
967
+ type="button"
968
+ onMouseDown={(e) => e.stopPropagation()}
969
+ onClick={() => void runCode()}
970
+ disabled={running}
971
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-emerald-600/20 hover:bg-emerald-600/40 text-emerald-400 hover:text-emerald-300 disabled:opacity-40 transition-colors shrink-0"
972
+ title="Run (Ctrl+Enter)"
973
+ >
974
+ {running ? (
975
+ <Loader2 className="w-3 h-3 animate-spin" />
976
+ ) : (
977
+ <Play className="w-3 h-3" />
978
+ )}
979
+ Run
980
+ </button>
981
+
982
+ {/* Clear output */}
983
+ <button
984
+ type="button"
985
+ onMouseDown={(e) => e.stopPropagation()}
986
+ onClick={() =>
987
+ navigator.clipboard.writeText(
988
+ output.map((l) => l.text).join("\n"),
989
+ )
990
+ }
991
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
992
+ title="Copy output"
993
+ >
994
+ <Copy className="w-3.5 h-3.5" />
995
+ </button>
996
+ <button
997
+ type="button"
998
+ onMouseDown={(e) => e.stopPropagation()}
999
+ onClick={() => {
1000
+ setOutput([]);
1001
+ setDurationMs(null);
1002
+ setTimedOut(false);
1003
+ }}
1004
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
1005
+ title="Clear output"
1006
+ >
1007
+ <Trash2 className="w-3.5 h-3.5" />
1008
+ </button>
1009
+ </>
1010
+ )}
1011
+
1012
+ {/* Sandbox: save + clear */}
1013
+ {mode === "sandbox" && (
1014
+ <>
1015
+ {currentQuestion && !sbxNaming && (
1016
+ <>
1017
+ {/* Save — overwrites when a sandbox is already active, otherwise shows naming flow */}
1018
+ <button
1019
+ type="button"
1020
+ onMouseDown={(e) => e.stopPropagation()}
1021
+ onClick={async () => {
1022
+ if (activeSandboxId) {
1023
+ await overwriteSandboxSnippet();
1024
+ } else {
1025
+ setSbxName("My Sandbox");
1026
+ setSbxNaming(true);
1027
+ setTimeout(() => sbxNameInputRef.current?.focus(), 30);
1028
+ }
1029
+ }}
1030
+ disabled={sbxSaving}
1031
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium text-slate-400 hover:text-violet-300 hover:bg-violet-600/10 transition-colors shrink-0"
1032
+ title={
1033
+ activeSandboxId
1034
+ ? "Overwrite saved sandbox"
1035
+ : "Save both server and client code as a sandbox"
1036
+ }
1037
+ >
1038
+ {sbxSaved ? (
1039
+ <>
1040
+ <Check className="w-3 h-3 text-violet-400" /> Saved
1041
+ </>
1042
+ ) : sbxSaving ? (
1043
+ <Loader2 className="w-3 h-3 animate-spin" />
1044
+ ) : (
1045
+ <>
1046
+ <Save className="w-3 h-3" /> Save
1047
+ </>
1048
+ )}
1049
+ </button>
1050
+ {/* Save As — always creates a new sandbox */}
1051
+ {activeSandboxId && (
1052
+ <button
1053
+ type="button"
1054
+ onMouseDown={(e) => e.stopPropagation()}
1055
+ onClick={() => {
1056
+ setSbxName("My Sandbox");
1057
+ setSbxNaming(true);
1058
+ setTimeout(() => sbxNameInputRef.current?.focus(), 30);
1059
+ }}
1060
+ disabled={sbxSaving}
1061
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium text-slate-400 hover:text-violet-300 hover:bg-violet-600/10 transition-colors shrink-0"
1062
+ title="Save as a new sandbox"
1063
+ >
1064
+ <Save className="w-3 h-3" /> Save As
1065
+ </button>
1066
+ )}
1067
+ </>
1068
+ )}
1069
+ {sbxNaming && (
1070
+ <div
1071
+ className="flex items-center gap-1 shrink-0"
1072
+ onMouseDown={(e) => e.stopPropagation()}
1073
+ >
1074
+ <input
1075
+ ref={sbxNameInputRef}
1076
+ value={sbxName}
1077
+ onChange={(e) => setSbxName(e.target.value)}
1078
+ onKeyDown={async (e) => {
1079
+ if (e.key === "Enter") {
1080
+ e.preventDefault();
1081
+ setSbxNaming(false);
1082
+ await saveSandboxSnippet(sbxName.trim());
1083
+ } else if (e.key === "Escape") {
1084
+ setSbxNaming(false);
1085
+ }
1086
+ }}
1087
+ placeholder="Sandbox name…"
1088
+ className="w-32 bg-slate-900 border border-violet-600/50 rounded px-2 py-0.5 text-[11px] text-slate-200 placeholder-slate-600 outline-none focus:border-violet-500"
1089
+ />
1090
+ <button
1091
+ type="button"
1092
+ onClick={async () => {
1093
+ setSbxNaming(false);
1094
+ await saveSandboxSnippet(sbxName.trim());
1095
+ }}
1096
+ className="p-0.5 rounded bg-violet-600/20 hover:bg-violet-600/40 text-violet-400 transition-colors"
1097
+ title="Confirm (Enter)"
1098
+ >
1099
+ {sbxSaving ? (
1100
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
1101
+ ) : (
1102
+ <Check className="w-3.5 h-3.5" />
1103
+ )}
1104
+ </button>
1105
+ <button
1106
+ type="button"
1107
+ onClick={() => setSbxNaming(false)}
1108
+ className="p-0.5 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors"
1109
+ title="Cancel (Esc)"
1110
+ >
1111
+ <X className="w-3.5 h-3.5" />
1112
+ </button>
1113
+ </div>
1114
+ )}
1115
+ <button
1116
+ type="button"
1117
+ onMouseDown={(e) => e.stopPropagation()}
1118
+ onClick={() =>
1119
+ navigator.clipboard.writeText(
1120
+ sandboxOutput.map((l) => l.text).join("\n"),
1121
+ )
1122
+ }
1123
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
1124
+ title="Copy output"
1125
+ >
1126
+ <Copy className="w-3.5 h-3.5" />
1127
+ </button>
1128
+ <button
1129
+ type="button"
1130
+ onMouseDown={(e) => e.stopPropagation()}
1131
+ onClick={() => setSandboxOutput([])}
1132
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
1133
+ title="Clear output"
1134
+ >
1135
+ <Trash2 className="w-3.5 h-3.5" />
1136
+ </button>
1137
+ </>
1138
+ )}
1139
+
1140
+ <button
1141
+ onMouseDown={(e) => e.stopPropagation()}
1142
+ onClick={toggleMax}
1143
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
1144
+ title={maximized ? "Restore" : "Maximise"}
1145
+ >
1146
+ {maximized ? (
1147
+ <Minimize2 className="w-3.5 h-3.5" />
1148
+ ) : (
1149
+ <Maximize2 className="w-3.5 h-3.5" />
1150
+ )}
1151
+ </button>
1152
+ <button
1153
+ onMouseDown={(e) => e.stopPropagation()}
1154
+ onClick={closeCodeRunner}
1155
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
1156
+ title="Close (Esc)"
1157
+ >
1158
+ <X className="w-3.5 h-3.5" />
1159
+ </button>
1160
+ </div>
1161
+
1162
+ {/* ── Body ── */}
1163
+ {mode === "script" ? (
1164
+ <div className="flex flex-col flex-1 overflow-hidden">
1165
+ {/* Editor pane */}
1166
+ <div className="flex-1 min-h-0 relative">
1167
+ <SyntaxEditor
1168
+ value={code}
1169
+ onChange={setCode}
1170
+ onCtrlEnter={() => void runCode()}
1171
+ language={lang}
1172
+ autoFocus
1173
+ fontSize="13px"
1174
+ focusRingClass="ring-violet-500/40"
1175
+ placeholder={`// TypeScript / JavaScript\n// Ctrl+Enter to run\n\nconst res = await fetch('https://jsonplaceholder.typicode.com/todos/1');\nconst data = await res.json();\nconsole.log(data);`}
1176
+ />
1177
+ </div>
1178
+
1179
+ {/* Divider */}
1180
+ <div
1181
+ className="h-px bg-slate-700 shrink-0"
1182
+ onMouseDown={startResize("s")}
1183
+ />
1184
+
1185
+ {/* Output pane */}
1186
+ <div className="h-44 bg-slate-950 flex flex-col overflow-hidden shrink-0">
1187
+ <div className="flex items-center gap-2 px-3 py-1 bg-slate-900 border-b border-slate-800 shrink-0">
1188
+ <span className="text-[10px] uppercase tracking-wider text-slate-500 font-medium">
1189
+ Output
1190
+ </span>
1191
+ {running && (
1192
+ <Loader2 className="w-3 h-3 text-emerald-400 animate-spin ml-auto" />
1193
+ )}
1194
+ {!running && durationMs !== null && (
1195
+ <span className="ml-auto flex items-center gap-1 text-[10px] text-slate-600">
1196
+ <Clock className="w-3 h-3" />
1197
+ {durationMs}ms{timedOut ? " · timed out" : ""}
1198
+ </span>
1199
+ )}
1200
+ </div>
1201
+ <div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[12px] leading-relaxed">
1202
+ {output.length === 0 && !running && (
1203
+ <span className="text-slate-600">
1204
+ Press Run (Ctrl+Enter) to execute
1205
+ </span>
1206
+ )}
1207
+ {output.map((line, i) => (
1208
+ <div
1209
+ key={i}
1210
+ className={
1211
+ line.kind === "stderr"
1212
+ ? "text-red-400 whitespace-pre-wrap"
1213
+ : line.kind === "warn"
1214
+ ? "text-amber-400 whitespace-pre-wrap"
1215
+ : line.kind === "info"
1216
+ ? "text-slate-500 italic whitespace-pre-wrap"
1217
+ : "text-slate-200 whitespace-pre-wrap"
1218
+ }
1219
+ >
1220
+ {line.text}
1221
+ </div>
1222
+ ))}
1223
+ <div ref={outputEndRef} />
1224
+ </div>
1225
+ </div>
1226
+ </div>
1227
+ ) : (
1228
+ /* ── Sandbox mode ── */
1229
+ <div className="flex flex-col flex-1 overflow-hidden">
1230
+ {/* Two-pane editor row */}
1231
+ <div
1232
+ ref={sbxEditorRowRef}
1233
+ className="flex flex-1 min-h-0 overflow-hidden"
1234
+ >
1235
+ {/* ── Server pane ── */}
1236
+ {serverCollapsed ? (
1237
+ /* Collapsed: slim vertical tab always visible so user can re-open */
1238
+ <button
1239
+ type="button"
1240
+ onClick={() => setServerCollapsed(false)}
1241
+ className="flex flex-col items-center justify-center w-7 shrink-0 bg-slate-800/60 border-r border-slate-700 text-slate-500 hover:text-emerald-400 hover:bg-slate-800 transition-colors gap-1.5 py-3"
1242
+ title="Expand server"
1243
+ >
1244
+ <Server className="w-3 h-3" />
1245
+ <span
1246
+ className="text-[8px] uppercase tracking-widest font-medium"
1247
+ style={{
1248
+ writingMode: "vertical-rl",
1249
+ transform: "rotate(180deg)",
1250
+ }}
1251
+ >
1252
+ Server
1253
+ </span>
1254
+ <ChevronRight className="w-3 h-3" />
1255
+ </button>
1256
+ ) : (
1257
+ <div
1258
+ className="flex flex-col min-w-0 overflow-hidden"
1259
+ style={
1260
+ clientCollapsed
1261
+ ? { flex: "1 1 0" }
1262
+ : { width: `${sbxSplit}%`, minWidth: 0 }
1263
+ }
1264
+ >
1265
+ <div className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800/60 border-b border-slate-700 shrink-0">
1266
+ <Server className="w-3 h-3 text-emerald-500/70 shrink-0" />
1267
+ <span className="text-[10px] uppercase tracking-wider text-slate-500 font-medium flex-1">
1268
+ Server
1269
+ </span>
1270
+ {serverRunning && (
1271
+ <span className="flex items-center gap-1 text-[9px] font-mono text-emerald-400 shrink-0">
1272
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse inline-block" />
1273
+ :{sandboxPort}
1274
+ </span>
1275
+ )}
1276
+ <div className="flex items-center gap-0.5">
1277
+ {LANG_OPTIONS.map((l) => (
1278
+ <button
1279
+ key={l}
1280
+ type="button"
1281
+ onClick={() => setServerLang(l)}
1282
+ className={`px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wider font-mono transition-colors ${
1283
+ serverLang === l
1284
+ ? "bg-violet-600/30 text-violet-300"
1285
+ : "text-slate-600 hover:text-slate-300"
1286
+ }`}
1287
+ >
1288
+ {l === "typescript" ? "TS" : "JS"}
1289
+ </button>
1290
+ ))}
1291
+ </div>
1292
+ <button
1293
+ type="button"
1294
+ onClick={() => {
1295
+ if (!clientCollapsed) setServerCollapsed((v) => !v);
1296
+ }}
1297
+ disabled={clientCollapsed}
1298
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
1299
+ title="Collapse server"
1300
+ >
1301
+ <ChevronLeft className="w-3 h-3" />
1302
+ </button>
1303
+ {!serverRunning ? (
1304
+ <button
1305
+ type="button"
1306
+ onClick={() => void startServer()}
1307
+ disabled={serverStarting}
1308
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-emerald-600/20 hover:bg-emerald-600/40 text-emerald-400 disabled:opacity-40 transition-colors shrink-0"
1309
+ title="Start server (Ctrl+Enter in editor)"
1310
+ >
1311
+ {serverStarting ? (
1312
+ <Loader2 className="w-3 h-3 animate-spin" />
1313
+ ) : (
1314
+ <Play className="w-3 h-3" />
1315
+ )}
1316
+ Start
1317
+ </button>
1318
+ ) : (
1319
+ <button
1320
+ type="button"
1321
+ onClick={() => void stopServer()}
1322
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-red-600/20 hover:bg-red-600/40 text-red-400 transition-colors shrink-0"
1323
+ title="Stop server"
1324
+ >
1325
+ <StopCircle className="w-3 h-3" />
1326
+ Stop
1327
+ </button>
1328
+ )}
1329
+ </div>
1330
+ <div className="flex-1 min-h-0 relative">
1331
+ <SyntaxEditor
1332
+ value={serverCode}
1333
+ onChange={setServerCode}
1334
+ onCtrlEnter={() => void startServer()}
1335
+ language={serverLang}
1336
+ fontSize="12px"
1337
+ focusRingClass="ring-emerald-500/30"
1338
+ placeholder={
1339
+ "import express from 'express';\n// process.env.PORT is auto-injected\n// Ctrl+Enter to start"
1340
+ }
1341
+ />
1342
+ </div>
1343
+ </div>
1344
+ )}
1345
+ {!serverCollapsed && !clientCollapsed && (
1346
+ <div
1347
+ className="w-1.5 bg-slate-700 hover:bg-violet-500/60 cursor-col-resize shrink-0 transition-colors"
1348
+ onMouseDown={(e) => {
1349
+ e.preventDefault();
1350
+ const containerW = sbxEditorRowRef.current?.offsetWidth ?? 1;
1351
+ sbxDividerDrag.current = {
1352
+ startX: e.clientX,
1353
+ startPct: sbxSplit,
1354
+ containerW,
1355
+ };
1356
+ }}
1357
+ />
1358
+ )}
1359
+
1360
+ {/* ── Client pane ── */}
1361
+ {clientCollapsed ? (
1362
+ /* Collapsed: slim vertical tab always visible so user can re-open */
1363
+ <button
1364
+ type="button"
1365
+ onClick={() => setClientCollapsed(false)}
1366
+ className="flex flex-col items-center justify-center w-7 shrink-0 bg-slate-800/60 border-l border-slate-700 text-slate-500 hover:text-cyan-400 hover:bg-slate-800 transition-colors gap-1.5 py-3"
1367
+ title="Expand client"
1368
+ >
1369
+ <Globe className="w-3 h-3" />
1370
+ <span
1371
+ className="text-[8px] uppercase tracking-widest font-medium"
1372
+ style={{
1373
+ writingMode: "vertical-rl",
1374
+ transform: "rotate(180deg)",
1375
+ }}
1376
+ >
1377
+ Client
1378
+ </span>
1379
+ <ChevronLeft className="w-3 h-3" />
1380
+ </button>
1381
+ ) : (
1382
+ <div
1383
+ className="flex flex-col min-w-0 overflow-hidden"
1384
+ style={{ flex: "1 1 0" }}
1385
+ >
1386
+ <div className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800/60 border-b border-slate-700 shrink-0">
1387
+ <Globe className="w-3 h-3 text-cyan-500/70 shrink-0" />
1388
+ <span className="text-[10px] uppercase tracking-wider text-slate-500 font-medium flex-1">
1389
+ Client
1390
+ </span>
1391
+ <button
1392
+ type="button"
1393
+ onClick={() => {
1394
+ if (!serverCollapsed) setClientCollapsed((v) => !v);
1395
+ }}
1396
+ disabled={serverCollapsed}
1397
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
1398
+ title="Collapse client"
1399
+ >
1400
+ <ChevronRight className="w-3 h-3" />
1401
+ </button>
1402
+ {sandboxUrl && (
1403
+ <span
1404
+ className="text-[9px] font-mono text-slate-600 truncate max-w-[90px]"
1405
+ title={sandboxUrl}
1406
+ >
1407
+ {sandboxUrl}
1408
+ </span>
1409
+ )}
1410
+ <div className="flex items-center gap-0.5">
1411
+ {LANG_OPTIONS.map((l) => (
1412
+ <button
1413
+ key={l}
1414
+ type="button"
1415
+ onClick={() => setClientLang(l)}
1416
+ className={`px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wider font-mono transition-colors ${
1417
+ clientLang === l
1418
+ ? "bg-violet-600/30 text-violet-300"
1419
+ : "text-slate-600 hover:text-slate-300"
1420
+ }`}
1421
+ >
1422
+ {l === "typescript" ? "TS" : "JS"}
1423
+ </button>
1424
+ ))}
1425
+ </div>
1426
+ <button
1427
+ type="button"
1428
+ onClick={() => void runClient()}
1429
+ disabled={clientRunning || !serverRunning}
1430
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-cyan-600/20 hover:bg-cyan-600/40 text-cyan-400 disabled:opacity-40 transition-colors shrink-0"
1431
+ title={
1432
+ serverRunning
1433
+ ? "Run client (Ctrl+Enter in editor)"
1434
+ : "Start the server first"
1435
+ }
1436
+ >
1437
+ {clientRunning ? (
1438
+ <Loader2 className="w-3 h-3 animate-spin" />
1439
+ ) : (
1440
+ <Play className="w-3 h-3" />
1441
+ )}
1442
+ Run
1443
+ </button>
1444
+ </div>
1445
+ <div className="flex-1 min-h-0 relative">
1446
+ <SyntaxEditor
1447
+ value={clientCode}
1448
+ onChange={setClientCode}
1449
+ onCtrlEnter={() => {
1450
+ if (serverRunning) void runClient();
1451
+ }}
1452
+ language={clientLang}
1453
+ fontSize="12px"
1454
+ focusRingClass="ring-cyan-500/30"
1455
+ placeholder={
1456
+ "// SANDBOX_URL is injected automatically\n// Start the server first, then Ctrl+Enter to run"
1457
+ }
1458
+ />
1459
+ </div>
1460
+ </div>
1461
+ )}
1462
+ </div>
1463
+
1464
+ {/* Horizontal resizer (output) */}
1465
+ <div
1466
+ className="h-1.5 bg-slate-700 hover:bg-violet-500/60 cursor-row-resize shrink-0 transition-colors"
1467
+ onMouseDown={(e) => {
1468
+ e.preventDefault();
1469
+ sbxOutputDrag.current = {
1470
+ startY: e.clientY,
1471
+ startH: outputCollapsed ? 0 : sbxOutputH,
1472
+ };
1473
+ if (outputCollapsed) setOutputCollapsed(false);
1474
+ }}
1475
+ />
1476
+
1477
+ {/* Sandbox output pane */}
1478
+ <div
1479
+ className="bg-slate-950 flex flex-col overflow-hidden shrink-0 transition-[height]"
1480
+ style={{ height: outputCollapsed ? 0 : sbxOutputH }}
1481
+ >
1482
+ <div className="flex items-center gap-2 px-3 py-1 bg-slate-900 border-b border-slate-800 shrink-0">
1483
+ <span className="text-[10px] uppercase tracking-wider text-slate-500 font-medium">
1484
+ Output
1485
+ </span>
1486
+ {(serverStarting || clientRunning) && (
1487
+ <Loader2 className="w-3 h-3 text-emerald-400 animate-spin" />
1488
+ )}
1489
+ <button
1490
+ type="button"
1491
+ className="ml-auto p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
1492
+ onClick={() => setOutputCollapsed((v) => !v)}
1493
+ title={outputCollapsed ? "Expand output" : "Collapse output"}
1494
+ >
1495
+ {outputCollapsed ? (
1496
+ <ChevronUp className="w-3 h-3" />
1497
+ ) : (
1498
+ <ChevronDown className="w-3 h-3" />
1499
+ )}
1500
+ </button>
1501
+ </div>
1502
+ <div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[12px] leading-relaxed">
1503
+ {sandboxOutput.length === 0 &&
1504
+ !serverStarting &&
1505
+ !clientRunning && (
1506
+ <span className="text-slate-600">
1507
+ Start the server, then run the client
1508
+ </span>
1509
+ )}
1510
+ {sandboxOutput.map((line, i) => (
1511
+ <div key={i} className="flex items-start gap-2">
1512
+ <span
1513
+ className={`shrink-0 text-[9px] font-bold mt-0.5 w-7 text-right ${
1514
+ line.source === "server"
1515
+ ? "text-emerald-600"
1516
+ : line.source === "client"
1517
+ ? "text-cyan-600"
1518
+ : "text-slate-700"
1519
+ }`}
1520
+ >
1521
+ {line.source === "server"
1522
+ ? "srv"
1523
+ : line.source === "client"
1524
+ ? "cli"
1525
+ : "···"}
1526
+ </span>
1527
+ <span
1528
+ className={
1529
+ line.kind === "stderr"
1530
+ ? "text-red-400 whitespace-pre-wrap"
1531
+ : line.kind === "warn"
1532
+ ? "text-amber-400 whitespace-pre-wrap"
1533
+ : line.kind === "info"
1534
+ ? "text-slate-500 italic whitespace-pre-wrap"
1535
+ : "text-slate-200 whitespace-pre-wrap"
1536
+ }
1537
+ >
1538
+ {line.text}
1539
+ </span>
1540
+ </div>
1541
+ ))}
1542
+ <div ref={outputEndRef} />
1543
+ </div>
1544
+ </div>
1545
+ </div>
1546
+ )}
1547
+ </div>
1548
+ );
1549
+ }