create-interview-cockpit 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +384 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +530 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1895 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +960 -0
  23. package/template/client/src/store.ts +250 -6
  24. package/template/client/src/types.ts +36 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/cockpit.json +1 -1
  27. package/template/server/src/google-drive.ts +39 -3
  28. package/template/server/src/index.ts +954 -52
  29. package/template/server/src/infra-runner.ts +1104 -0
  30. package/template/server/src/storage.ts +22 -3
@@ -25,6 +25,13 @@ import {
25
25
  ChevronRight,
26
26
  ChevronUp,
27
27
  ChevronDown,
28
+ Eye,
29
+ Code2,
30
+ FilePlus,
31
+ MessageSquare,
32
+ Send,
33
+ Clipboard,
34
+ ClipboardCheck,
28
35
  } from "lucide-react";
29
36
  import { useStore } from "../store";
30
37
  import Editor from "react-simple-code-editor";
@@ -32,6 +39,23 @@ import Prism from "prismjs";
32
39
  import "prismjs/components/prism-clike";
33
40
  import "prismjs/components/prism-javascript";
34
41
  import "prismjs/components/prism-typescript";
42
+ import {
43
+ generatePreviewHTML,
44
+ defaultForType,
45
+ resolveNextjsEntry,
46
+ type FrontendLabType,
47
+ } from "../reactLab";
48
+ import {
49
+ fetchModuleFederationStatus,
50
+ startModuleFederationSandbox,
51
+ startNextjsSandbox,
52
+ stopModuleFederationSandbox,
53
+ updateNextjsFiles,
54
+ updateModuleFederationFiles,
55
+ stopNextjsSandbox,
56
+ } from "../api";
57
+ import ReactMarkdown from "react-markdown";
58
+ import remarkGfm from "remark-gfm";
35
59
 
36
60
  const MIN_W = 420;
37
61
  const MIN_H = 300;
@@ -48,6 +72,7 @@ interface OutputLine {
48
72
 
49
73
  const LANG_OPTIONS = ["typescript", "javascript"] as const;
50
74
  type Lang = (typeof LANG_OPTIONS)[number];
75
+ type FrontendClientType = "script" | FrontendLabType;
51
76
 
52
77
  // ── Sandbox default snippets ─────────────────────────────────────────
53
78
  const DEFAULT_SERVER_CODE = `import express from 'express';
@@ -183,12 +208,56 @@ function SyntaxEditor({
183
208
  );
184
209
  }
185
210
 
211
+ interface FileTreeNode {
212
+ name: string;
213
+ path: string;
214
+ children: FileTreeNode[];
215
+ files: string[];
216
+ }
217
+
218
+ function buildFileTree(paths: string[]): FileTreeNode {
219
+ const root: FileTreeNode = {
220
+ name: "",
221
+ path: "",
222
+ children: [],
223
+ files: [],
224
+ };
225
+
226
+ for (const filePath of paths) {
227
+ const parts = filePath.split("/");
228
+ let node = root;
229
+
230
+ for (let index = 0; index < parts.length - 1; index += 1) {
231
+ const name = parts[index];
232
+ const path = parts.slice(0, index + 1).join("/");
233
+ let child = node.children.find((entry) => entry.path === path);
234
+
235
+ if (!child) {
236
+ child = {
237
+ name,
238
+ path,
239
+ children: [],
240
+ files: [],
241
+ };
242
+ node.children.push(child);
243
+ }
244
+
245
+ node = child;
246
+ }
247
+
248
+ node.files.push(filePath);
249
+ }
250
+
251
+ return root;
252
+ }
253
+
186
254
  export default function CodeRunnerModal() {
187
255
  const {
188
256
  closeCodeRunner,
189
257
  runnerInitialCode,
190
258
  runnerInitialLanguage,
191
259
  runnerInitialSandbox,
260
+ runnerInitialFileId,
192
261
  currentQuestion,
193
262
  saveCodeSnippetToQuestion,
194
263
  overwriteContextFileContent,
@@ -206,6 +275,10 @@ export default function CodeRunnerModal() {
206
275
  const [saving, setSaving] = useState(false);
207
276
  const [naming, setNaming] = useState(false);
208
277
  const [snippetName, setSnippetName] = useState("");
278
+ /** Non-null when editing a previously saved script — Save overwrites, Save As creates new */
279
+ const [activeScriptId, setActiveScriptId] = useState<string | null>(
280
+ runnerInitialFileId ?? null,
281
+ );
209
282
 
210
283
  // ── Sandbox state ─────────────────────────────────────────
211
284
  const [mode, setMode] = useState<"script" | "sandbox">("script");
@@ -221,6 +294,41 @@ export default function CodeRunnerModal() {
221
294
  const [sandboxOutput, setSandboxOutput] = useState<OutputLine[]>([]);
222
295
  const [clientRunning, setClientRunning] = useState(false);
223
296
 
297
+ // ── React/Next.js client state ──────────────────────────────
298
+ const [clientType, setClientType] = useState<FrontendClientType>("script");
299
+ const [reactFiles, setReactFiles] = useState<Record<string, string>>({});
300
+ const [reactActiveFile, setReactActiveFile] = useState<string>("");
301
+ const [reactClientTab, setReactClientTab] = useState<"edit" | "preview">(
302
+ "edit",
303
+ );
304
+ const [reactPreviewSrc, setReactPreviewSrc] = useState<string | null>(null);
305
+ const [reactAddingFile, setReactAddingFile] = useState(false);
306
+ const [reactNewFileName, setReactNewFileName] = useState("");
307
+ // Folders that are collapsed in the Next.js file tree sidebar
308
+ const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
309
+ new Set(),
310
+ );
311
+ // Real Next.js dev-server state
312
+ const [nxSandboxId, setNxSandboxId] = useState<string | null>(null);
313
+ const [nxSandboxUrl, setNxSandboxUrl] = useState<string | null>(null);
314
+ const [nxStarting, setNxStarting] = useState(false);
315
+ const [nxError, setNxError] = useState<string | null>(null);
316
+ const nxIframeRef = useRef<HTMLIFrameElement>(null);
317
+ const [mfSandboxId, setMfSandboxId] = useState<string | null>(null);
318
+ const [mfHostUrl, setMfHostUrl] = useState<string | null>(null);
319
+ const [mfAppUrls, setMfAppUrls] = useState<Record<string, string>>({});
320
+ const [mfStarting, setMfStarting] = useState(false);
321
+ const [mfError, setMfError] = useState<string | null>(null);
322
+ const [mfPreviewApp, setMfPreviewApp] = useState("host");
323
+ // Simulated URL bar state for Next.js mode
324
+ const [reactPreviewPath, setReactPreviewPath] = useState("/");
325
+ const [reactNavInput, setReactNavInput] = useState("/");
326
+ const [reactNavHistory, setReactNavHistory] = useState<string[]>(["/"]);
327
+ const [reactNavIndex, setReactNavIndex] = useState(0);
328
+
329
+ // ── Sandbox output tab ("output" | "chat") ──────────────────
330
+ const [sbxBottomTab, setSbxBottomTab] = useState<"output" | "chat">("output");
331
+
224
332
  // ── Sandbox panel sizes ─────────────────────────────────────────
225
333
  // sbxSplit: server pane width as % of the editor row (0–100)
226
334
  const [sbxSplit, setSbxSplit] = useState(50);
@@ -246,23 +354,94 @@ export default function CodeRunnerModal() {
246
354
  const [activeSandboxId, setActiveSandboxId] = useState<string | null>(null);
247
355
  const sbxNameInputRef = useRef<HTMLInputElement>(null);
248
356
 
249
- // Save server+client together as one JSON blob with origin 'sandbox'
357
+ // ── Sandbox chat state (declared after activeSandboxId) ──────────────
358
+ type SbxChatMessage = {
359
+ id: string;
360
+ role: "user" | "assistant";
361
+ content: string;
362
+ };
363
+ const sbxChatKey = `sbx-chat:${activeSandboxId ?? `q:${currentQuestion?.id ?? "_"}`}`;
364
+ const sbxChatKeyRef = useRef(sbxChatKey);
365
+ const [sbxChatMessages, setSbxChatMessages] = useState<SbxChatMessage[]>(
366
+ () => {
367
+ try {
368
+ const s = localStorage.getItem(sbxChatKey);
369
+ return s ? (JSON.parse(s) as SbxChatMessage[]) : [];
370
+ } catch {
371
+ return [];
372
+ }
373
+ },
374
+ );
375
+ const [sbxChatInput, setSbxChatInput] = useState("");
376
+ const [sbxChatLoading, setSbxChatLoading] = useState(false);
377
+ const sbxChatScrollRef = useRef<HTMLDivElement>(null);
378
+ const sbxChatInputRef = useRef<HTMLTextAreaElement>(null);
379
+ const sbxChatAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
380
+ const [sbxChatCopiedId, setSbxChatCopiedId] = useState<string | null>(null);
381
+
382
+ // Keep key ref fresh
383
+ useEffect(() => {
384
+ sbxChatKeyRef.current = sbxChatKey;
385
+ }, [sbxChatKey]);
386
+ // Reload chat when artifact changes
387
+ useEffect(() => {
388
+ try {
389
+ const s = localStorage.getItem(sbxChatKey);
390
+ setSbxChatMessages(s ? (JSON.parse(s) as SbxChatMessage[]) : []);
391
+ } catch {
392
+ setSbxChatMessages([]);
393
+ }
394
+ }, [sbxChatKey]);
395
+ // Persist chat
396
+ useEffect(() => {
397
+ if (sbxChatMessages.length === 0) {
398
+ localStorage.removeItem(sbxChatKeyRef.current);
399
+ return;
400
+ }
401
+ localStorage.setItem(
402
+ sbxChatKeyRef.current,
403
+ JSON.stringify(sbxChatMessages),
404
+ );
405
+ }, [sbxChatMessages]);
406
+ // Auto-scroll chat
407
+ useEffect(() => {
408
+ if (sbxBottomTab === "chat" && sbxChatScrollRef.current)
409
+ sbxChatScrollRef.current.scrollTop =
410
+ sbxChatScrollRef.current.scrollHeight;
411
+ }, [sbxChatMessages, sbxChatLoading, sbxBottomTab]);
412
+
413
+ // Save server+client together as one JSON blob
250
414
  const saveSandboxSnippet = async (label: string) => {
251
415
  if (!currentQuestion) return;
252
416
  setSbxSaving(true);
253
417
  try {
254
- const payload = JSON.stringify({
255
- serverCode,
256
- serverLang,
257
- clientCode,
258
- clientLang,
259
- });
418
+ const origin =
419
+ clientType === "react"
420
+ ? "react"
421
+ : clientType === "nextjs"
422
+ ? "nextjs"
423
+ : clientType === "module-federation"
424
+ ? "module-federation"
425
+ : "sandbox";
426
+ const payload = JSON.stringify(
427
+ clientType === "script"
428
+ ? { serverCode, serverLang, clientCode, clientLang }
429
+ : {
430
+ serverCode,
431
+ serverLang,
432
+ clientCode: "",
433
+ clientLang: "javascript",
434
+ clientType,
435
+ reactFiles,
436
+ reactActiveFile,
437
+ },
438
+ );
260
439
  const cf = await saveCodeSnippetToQuestion(
261
440
  currentQuestion.id,
262
441
  payload,
263
- "sandbox",
442
+ origin,
264
443
  label || "My Sandbox",
265
- "sandbox",
444
+ origin,
266
445
  );
267
446
  setActiveSandboxId(cf.id);
268
447
  setSbxSaved(true);
@@ -277,12 +456,19 @@ export default function CodeRunnerModal() {
277
456
  if (!currentQuestion || !activeSandboxId) return;
278
457
  setSbxSaving(true);
279
458
  try {
280
- const payload = JSON.stringify({
281
- serverCode,
282
- serverLang,
283
- clientCode,
284
- clientLang,
285
- });
459
+ const payload = JSON.stringify(
460
+ clientType === "script"
461
+ ? { serverCode, serverLang, clientCode, clientLang }
462
+ : {
463
+ serverCode,
464
+ serverLang,
465
+ clientCode: "",
466
+ clientLang: "javascript",
467
+ clientType,
468
+ reactFiles,
469
+ reactActiveFile,
470
+ },
471
+ );
286
472
  await overwriteContextFileContent(
287
473
  currentQuestion.id,
288
474
  activeSandboxId,
@@ -295,6 +481,23 @@ export default function CodeRunnerModal() {
295
481
  }
296
482
  };
297
483
 
484
+ // Overwrite the existing script snippet in-place
485
+ const overwriteScriptSnippet = async () => {
486
+ if (!currentQuestion || !activeScriptId) return;
487
+ setSaving(true);
488
+ try {
489
+ await overwriteContextFileContent(
490
+ currentQuestion.id,
491
+ activeScriptId,
492
+ code,
493
+ );
494
+ setSaved(true);
495
+ setTimeout(() => setSaved(false), 2000);
496
+ } finally {
497
+ setSaving(false);
498
+ }
499
+ };
500
+
298
501
  const outputEndRef = useRef<HTMLDivElement>(null);
299
502
  const nameInputRef = useRef<HTMLInputElement>(null);
300
503
  // Tracks how many server log lines have already been flushed to sandboxOutput
@@ -324,6 +527,34 @@ export default function CodeRunnerModal() {
324
527
  setClientLang((runnerInitialSandbox.clientLang as Lang) ?? "javascript");
325
528
  setSandboxOutput([]);
326
529
  setActiveSandboxId(runnerInitialSandbox.fileId ?? null);
530
+ // Restore client type and React/Next.js files
531
+ const ct =
532
+ (runnerInitialSandbox.clientType as FrontendClientType) ?? "script";
533
+ setClientType(ct);
534
+ if (ct !== "script") {
535
+ if (
536
+ runnerInitialSandbox.reactFiles &&
537
+ Object.keys(runnerInitialSandbox.reactFiles).length > 0
538
+ ) {
539
+ setReactFiles(runnerInitialSandbox.reactFiles);
540
+ setReactActiveFile(
541
+ runnerInitialSandbox.reactActiveFile ??
542
+ Object.keys(runnerInitialSandbox.reactFiles)[0] ??
543
+ "",
544
+ );
545
+ } else {
546
+ const defs = defaultForType(ct);
547
+ setReactFiles(defs.files);
548
+ setReactActiveFile(defs.activeFile);
549
+ }
550
+ setReactPreviewSrc(null);
551
+ setReactClientTab("edit");
552
+ if (ct === "module-federation") {
553
+ setServerCollapsed(true);
554
+ setClientCollapsed(false);
555
+ setMfPreviewApp("host");
556
+ }
557
+ }
327
558
  }, [runnerInitialSandbox]);
328
559
 
329
560
  // Auto-focus is handled inside SyntaxEditor via autoFocus prop.
@@ -595,7 +826,496 @@ export default function CodeRunnerModal() {
595
826
  return () => clearInterval(interval);
596
827
  }, [sandboxId]);
597
828
 
598
- // ── Sandbox handlers ─────────────────────────────────────
829
+ // ── React/Next.js helpers ─────────────────────────────────────
830
+
831
+ /** Seed sensible default content for a freshly created file. */
832
+ const newFileContent = (name: string): string => {
833
+ const base = name.split("/").pop() ?? name;
834
+ // Next.js special files
835
+ if (base === "page.tsx" || base === "page.ts") {
836
+ const routeSegments = name
837
+ .replace(/^app\//, "")
838
+ .replace(/\/page\.tsx?$/, "")
839
+ .split("/")
840
+ .filter(Boolean);
841
+ const routeName =
842
+ routeSegments.length === 0
843
+ ? "Home"
844
+ : routeSegments
845
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
846
+ .join("");
847
+ const urlPath =
848
+ routeSegments.length === 0 ? "/" : "/" + routeSegments.join("/");
849
+ return [
850
+ `// ${urlPath} → ${name}`,
851
+ `// Server Component by default — no "use client" needed unless you use hooks`,
852
+ ``,
853
+ `export default function ${routeName}Page() {`,
854
+ ` return (`,
855
+ ` <div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>`,
856
+ ` <h1 style={{ fontSize: "1.5rem", fontWeight: "bold" }}>${routeName}</h1>`,
857
+ ` <p style={{ color: "#64748b", marginTop: "0.5rem" }}>`,
858
+ ` You are on <code>${urlPath}</code>`,
859
+ ` </p>`,
860
+ ` <button`,
861
+ ` onClick={() => (window as any).__nxNavigate("/")}`,
862
+ ` style={{ marginTop: "1rem", padding: "0.5rem 1rem", cursor: "pointer",`,
863
+ ` borderRadius: "0.375rem", border: "1px solid #cbd5e1", background: "#f8fafc" }}`,
864
+ ` >`,
865
+ ` ← Back to Home`,
866
+ ` </button>`,
867
+ ` </div>`,
868
+ ` );`,
869
+ `}`,
870
+ ``,
871
+ ].join("\n");
872
+ }
873
+ if (base === "layout.tsx" || base === "layout.ts") {
874
+ return [
875
+ `// Root Layout — wraps ALL pages, persists across navigations`,
876
+ ``,
877
+ `export default function Layout({ children }: { children: React.ReactNode }) {`,
878
+ ` return (`,
879
+ ` <html lang="en">`,
880
+ ` <body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>`,
881
+ ` {children}`,
882
+ ` </body>`,
883
+ ` </html>`,
884
+ ` );`,
885
+ `}`,
886
+ ``,
887
+ ].join("\n");
888
+ }
889
+ if (base === "loading.tsx" || base === "loading.ts") {
890
+ return [
891
+ `// Shown automatically while the page is loading (Suspense boundary)`,
892
+ ``,
893
+ `export default function Loading() {`,
894
+ ` return <p style={{ padding: "2rem", color: "#64748b" }}>Loading…</p>;`,
895
+ `}`,
896
+ ``,
897
+ ].join("\n");
898
+ }
899
+ if (base === "error.tsx" || base === "error.ts") {
900
+ return [
901
+ `"use client"; // error boundaries must be Client Components`,
902
+ ``,
903
+ `export default function Error({ error, reset }: { error: Error; reset: () => void }) {`,
904
+ ` return (`,
905
+ ` <div style={{ padding: "2rem" }}>`,
906
+ ` <h2>Something went wrong</h2>`,
907
+ ` <pre style={{ color: "#dc2626", fontSize: "0.8rem" }}>{error.message}</pre>`,
908
+ ` <button onClick={reset}>Try again</button>`,
909
+ ` </div>`,
910
+ ` );`,
911
+ `}`,
912
+ ``,
913
+ ].join("\n");
914
+ }
915
+ return `// ${name}\n`;
916
+ };
917
+
918
+ const getReactEntry = (
919
+ files: Record<string, string>,
920
+ type: "react" | "nextjs",
921
+ ): string => {
922
+ if (type === "nextjs")
923
+ return files["app/page.tsx"]
924
+ ? "app/page.tsx"
925
+ : (Object.keys(files)[0] ?? "");
926
+ return files["App.tsx"] ? "App.tsx" : (Object.keys(files)[0] ?? "");
927
+ };
928
+
929
+ const refreshPreview = useCallback(
930
+ (overridePath?: string) => {
931
+ const type = clientType as "react" | "nextjs";
932
+ let entry: string;
933
+ if (type === "nextjs") {
934
+ const path = overridePath ?? reactPreviewPath;
935
+ const resolved = resolveNextjsEntry(reactFiles, path);
936
+ if (!resolved) {
937
+ // Show a proper 404 — don't fall back to the home page
938
+ const notFoundHTML = generatePreviewHTML(
939
+ {
940
+ "__404__.tsx": [
941
+ `export default function NotFound() {`,
942
+ ` const path = ${JSON.stringify(path)};`,
943
+ ` return (`,
944
+ ` <div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>`,
945
+ ` <h1 style={{ fontSize: "1.5rem", fontWeight: "bold", color: "#dc2626" }}>404 — Page Not Found</h1>`,
946
+ ` <p style={{ color: "#64748b", marginTop: "0.5rem" }}>`,
947
+ ` No file matches <code style={{ background: "#f1f5f9", padding: "0.1em 0.4em", borderRadius: "0.25rem" }}>{path}</code>`,
948
+ ` </p>`,
949
+ ` <p style={{ color: "#94a3b8", fontSize: "0.875rem", marginTop: "1rem" }}>`,
950
+ ` Create <code style={{ background: "#f1f5f9", padding: "0.1em 0.4em", borderRadius: "0.25rem" }}>app${path === "/" ? "" : path}/page.tsx</code> to make this route work.`,
951
+ ` </p>`,
952
+ ` <button onClick={() => (window as any).__nxNavigate("/")}`,
953
+ ` style={{ marginTop: "1.5rem", padding: "0.5rem 1rem", cursor: "pointer",`,
954
+ ` borderRadius: "0.375rem", border: "1px solid #cbd5e1", background: "#f8fafc" }}>`,
955
+ ` ← Back to Home`,
956
+ ` </button>`,
957
+ ` </div>`,
958
+ ` );`,
959
+ `}`,
960
+ ].join("\n"),
961
+ },
962
+ "__404__.tsx",
963
+ undefined,
964
+ true,
965
+ );
966
+ setReactPreviewSrc(notFoundHTML);
967
+ setReactClientTab("preview");
968
+ return;
969
+ }
970
+ entry = resolved;
971
+ } else {
972
+ entry = getReactEntry(reactFiles, type);
973
+ }
974
+ if (!entry) return;
975
+ const html = generatePreviewHTML(
976
+ reactFiles,
977
+ entry,
978
+ sandboxUrl ?? undefined,
979
+ type === "nextjs",
980
+ );
981
+ setReactPreviewSrc(html);
982
+ setReactClientTab("preview");
983
+ },
984
+ [clientType, reactFiles, sandboxUrl, reactPreviewPath],
985
+ );
986
+
987
+ // Auto-refresh preview when files change while the preview tab is visible
988
+ const reactFilesRef = useRef(reactFiles);
989
+ useEffect(() => {
990
+ if (reactFiles === reactFilesRef.current) return;
991
+ reactFilesRef.current = reactFiles;
992
+ if (reactClientTab === "preview" && reactPreviewSrc) {
993
+ refreshPreview();
994
+ }
995
+ }, [reactFiles, reactClientTab, reactPreviewSrc, refreshPreview]);
996
+
997
+ /** Navigate to a new path (updates URL bar + history + re-renders preview). */
998
+ const navigatePreview = useCallback(
999
+ (to: string) => {
1000
+ const path = to.startsWith("/") ? to : "/" + to;
1001
+ setReactPreviewPath(path);
1002
+ setReactNavInput(path);
1003
+ setReactNavHistory((prev) => {
1004
+ const trimmed = prev.slice(0, reactNavIndex + 1);
1005
+ return [...trimmed, path];
1006
+ });
1007
+ setReactNavIndex((i) => i + 1);
1008
+ refreshPreview(path);
1009
+ },
1010
+ [reactNavIndex, refreshPreview],
1011
+ );
1012
+
1013
+ // Listen for rlab-nav messages from the preview iframe
1014
+ useEffect(() => {
1015
+ const handler = (e: MessageEvent) => {
1016
+ if (e.data?.type === "rlab-nav" && typeof e.data.to === "string") {
1017
+ navigatePreview(e.data.to);
1018
+ }
1019
+ };
1020
+ window.addEventListener("message", handler);
1021
+ return () => window.removeEventListener("message", handler);
1022
+ }, [navigatePreview]);
1023
+
1024
+ /** Start a real Next.js dev-server for the lab and point the iframe at it. */
1025
+ const startNextjsServer = useCallback(async () => {
1026
+ if (nxStarting) return;
1027
+ setNxStarting(true);
1028
+ setNxError(null);
1029
+ try {
1030
+ const info = await startNextjsSandbox(reactFiles);
1031
+ setNxSandboxId(info.id);
1032
+ setNxSandboxUrl(info.url);
1033
+ setReactClientTab("preview");
1034
+ } catch (err: any) {
1035
+ setNxError(err?.message ?? String(err));
1036
+ } finally {
1037
+ setNxStarting(false);
1038
+ }
1039
+ }, [nxStarting, reactFiles]);
1040
+
1041
+ const startModuleFederationServer = useCallback(async () => {
1042
+ if (mfStarting) return;
1043
+ setMfStarting(true);
1044
+ setMfError(null);
1045
+ setSbxBottomTab("output");
1046
+ setSandboxOutput([
1047
+ {
1048
+ kind: "info",
1049
+ text: "Installing dependencies and starting webpack apps…",
1050
+ source: "server",
1051
+ },
1052
+ ]);
1053
+ try {
1054
+ const info = await startModuleFederationSandbox(reactFiles);
1055
+ setMfSandboxId(info.id);
1056
+ setMfHostUrl(info.hostUrl);
1057
+ setMfAppUrls(info.appUrls);
1058
+ setMfPreviewApp(
1059
+ info.appUrls.host ? "host" : (Object.keys(info.appUrls)[0] ?? "host"),
1060
+ );
1061
+ setReactClientTab("preview");
1062
+ setServerCollapsed(true);
1063
+ setClientCollapsed(false);
1064
+ setSandboxOutput((prev) => [
1065
+ ...prev,
1066
+ {
1067
+ kind: "info",
1068
+ text: `✓ Webpack host running at ${info.hostUrl}`,
1069
+ source: "server",
1070
+ },
1071
+ ]);
1072
+ } catch (err: any) {
1073
+ const message = err?.message ?? String(err);
1074
+ setMfError(message);
1075
+ setSandboxOutput((prev) => [
1076
+ ...prev,
1077
+ { kind: "stderr", text: message, source: "server" },
1078
+ ]);
1079
+ } finally {
1080
+ setMfStarting(false);
1081
+ }
1082
+ }, [mfStarting, reactFiles]);
1083
+
1084
+ const stopModuleFederationServer = useCallback(async () => {
1085
+ if (!mfSandboxId) return;
1086
+ await stopModuleFederationSandbox(mfSandboxId).catch(() => {});
1087
+ setMfSandboxId(null);
1088
+ setMfHostUrl(null);
1089
+ setMfAppUrls({});
1090
+ setMfError(null);
1091
+ setSandboxOutput((prev) => [
1092
+ ...prev,
1093
+ {
1094
+ kind: "info",
1095
+ text: "Webpack module federation lab stopped.",
1096
+ source: "server",
1097
+ },
1098
+ ]);
1099
+ }, [mfSandboxId]);
1100
+
1101
+ /** Push updated files to the running Next.js server (HMR picks them up). */
1102
+ const pushNextjsFiles = useCallback(
1103
+ async (files: Record<string, string>) => {
1104
+ if (!nxSandboxId) return;
1105
+ try {
1106
+ await updateNextjsFiles(nxSandboxId, files);
1107
+ } catch {
1108
+ // non-fatal; HMR may already have picked up the change
1109
+ }
1110
+ },
1111
+ [nxSandboxId],
1112
+ );
1113
+
1114
+ const pushModuleFederationFiles = useCallback(
1115
+ async (files: Record<string, string>) => {
1116
+ if (!mfSandboxId) return;
1117
+ try {
1118
+ await updateModuleFederationFiles(mfSandboxId, files);
1119
+ } catch (err: any) {
1120
+ setMfError(err?.message ?? String(err));
1121
+ }
1122
+ },
1123
+ [mfSandboxId],
1124
+ );
1125
+
1126
+ // Auto-push file changes to the running Next.js server
1127
+ const nxFilesRef = useRef(reactFiles);
1128
+ useEffect(() => {
1129
+ if (!nxSandboxId || reactFiles === nxFilesRef.current) return;
1130
+ nxFilesRef.current = reactFiles;
1131
+ void pushNextjsFiles(reactFiles);
1132
+ }, [reactFiles, nxSandboxId, pushNextjsFiles]);
1133
+
1134
+ const mfFilesRef = useRef(reactFiles);
1135
+ useEffect(() => {
1136
+ if (!mfSandboxId || reactFiles === mfFilesRef.current) return;
1137
+ mfFilesRef.current = reactFiles;
1138
+ void pushModuleFederationFiles(reactFiles);
1139
+ }, [reactFiles, mfSandboxId, pushModuleFederationFiles]);
1140
+
1141
+ useEffect(() => {
1142
+ if (!mfSandboxId) return;
1143
+ const interval = setInterval(async () => {
1144
+ try {
1145
+ const status = await fetchModuleFederationStatus(mfSandboxId);
1146
+ if (!status.running) {
1147
+ setMfSandboxId(null);
1148
+ setMfHostUrl(null);
1149
+ setMfAppUrls({});
1150
+ setMfError(null);
1151
+ return;
1152
+ }
1153
+ if (status.hostUrl) setMfHostUrl(status.hostUrl);
1154
+ if (status.appUrls) {
1155
+ setMfAppUrls(status.appUrls);
1156
+ setMfPreviewApp((prev) =>
1157
+ status.appUrls?.[prev]
1158
+ ? prev
1159
+ : (Object.keys(status.appUrls ?? {})[0] ?? "host"),
1160
+ );
1161
+ }
1162
+ if (status.logs) {
1163
+ setSandboxOutput(
1164
+ status.logs.flatMap((chunk) =>
1165
+ chunk
1166
+ .split("\n")
1167
+ .filter(Boolean)
1168
+ .map((text) => ({
1169
+ kind: "stdout" as const,
1170
+ text,
1171
+ source: "server" as const,
1172
+ })),
1173
+ ),
1174
+ );
1175
+ }
1176
+ } catch {
1177
+ /* ignore transient network errors */
1178
+ }
1179
+ }, 1000);
1180
+ return () => clearInterval(interval);
1181
+ }, [mfSandboxId]);
1182
+
1183
+ // Clean up Next.js server when the modal is closed or mode changes away from nextjs
1184
+ const prevClientTypeRef = useRef(clientType);
1185
+ useEffect(() => {
1186
+ const prev = prevClientTypeRef.current;
1187
+ prevClientTypeRef.current = clientType;
1188
+ if (prev === "nextjs" && clientType !== "nextjs" && nxSandboxId) {
1189
+ void stopNextjsSandbox(nxSandboxId);
1190
+ setNxSandboxId(null);
1191
+ setNxSandboxUrl(null);
1192
+ }
1193
+ if (
1194
+ prev === "module-federation" &&
1195
+ clientType !== "module-federation" &&
1196
+ mfSandboxId
1197
+ ) {
1198
+ void stopModuleFederationSandbox(mfSandboxId);
1199
+ setMfSandboxId(null);
1200
+ setMfHostUrl(null);
1201
+ setMfAppUrls({});
1202
+ }
1203
+ }, [clientType, nxSandboxId, mfSandboxId]);
1204
+
1205
+ // Clean up on unmount
1206
+ useEffect(() => {
1207
+ return () => {
1208
+ if (nxSandboxId) void stopNextjsSandbox(nxSandboxId);
1209
+ if (mfSandboxId) void stopModuleFederationSandbox(mfSandboxId);
1210
+ };
1211
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1212
+ }, [nxSandboxId, mfSandboxId]);
1213
+
1214
+ const handleClientTypeChange = useCallback(
1215
+ (ct: FrontendClientType) => {
1216
+ if (ct === clientType) return;
1217
+ setClientType(ct);
1218
+ if (ct !== "script") {
1219
+ const defs = defaultForType(ct);
1220
+ setReactFiles(defs.files);
1221
+ setReactActiveFile(defs.activeFile);
1222
+ setReactPreviewSrc(null);
1223
+ setReactClientTab("edit");
1224
+ if (ct === "nextjs") {
1225
+ setReactPreviewPath("/");
1226
+ setReactNavInput("/");
1227
+ setReactNavHistory(["/"]);
1228
+ setReactNavIndex(0);
1229
+ }
1230
+ if (ct === "module-federation") {
1231
+ setServerCollapsed(true);
1232
+ setClientCollapsed(false);
1233
+ setMfPreviewApp("host");
1234
+ setMfError(null);
1235
+ }
1236
+ }
1237
+ },
1238
+ [clientType],
1239
+ );
1240
+
1241
+ // ── Sandbox chat handler ──────────────────────────────────
1242
+
1243
+ const handleSbxChatSend = useCallback(async () => {
1244
+ const text = sbxChatInput.trim();
1245
+ if (!text || sbxChatLoading) return;
1246
+ setSbxChatInput("");
1247
+ const userMsg: SbxChatMessage = {
1248
+ id: crypto.randomUUID(),
1249
+ role: "user",
1250
+ content: text,
1251
+ };
1252
+ setSbxChatMessages((prev) => [...prev, userMsg]);
1253
+ setSbxChatLoading(true);
1254
+ const abort = { aborted: false };
1255
+ sbxChatAbortRef.current = abort;
1256
+ const aId = crypto.randomUUID();
1257
+ setSbxChatMessages((prev) => [
1258
+ ...prev,
1259
+ { id: aId, role: "assistant", content: "" },
1260
+ ]);
1261
+ const isFrontendMode =
1262
+ clientType === "react" ||
1263
+ clientType === "nextjs" ||
1264
+ clientType === "module-federation";
1265
+ const workspaceFiles = isFrontendMode
1266
+ ? reactFiles
1267
+ : { "client.js": clientCode, "server.ts": serverCode };
1268
+ const labType: FrontendLabType =
1269
+ clientType === "nextjs"
1270
+ ? "nextjs"
1271
+ : clientType === "module-federation"
1272
+ ? "module-federation"
1273
+ : "react";
1274
+ try {
1275
+ const history = [...sbxChatMessages, userMsg].map((m) => ({
1276
+ role: m.role,
1277
+ content: m.content,
1278
+ }));
1279
+ const { streamFrontendLabAsk } = await import("../api");
1280
+ await streamFrontendLabAsk(
1281
+ {
1282
+ messages: history,
1283
+ workspace: workspaceFiles,
1284
+ labType,
1285
+ questionId: currentQuestion?.id,
1286
+ },
1287
+ (delta) => {
1288
+ if (abort.aborted) return;
1289
+ setSbxChatMessages((prev) =>
1290
+ prev.map((m) =>
1291
+ m.id === aId ? { ...m, content: m.content + delta } : m,
1292
+ ),
1293
+ );
1294
+ },
1295
+ );
1296
+ } catch (err: unknown) {
1297
+ if (!abort.aborted)
1298
+ setSbxChatMessages((prev) =>
1299
+ prev.map((m) =>
1300
+ m.id === aId
1301
+ ? { ...m, content: (err as Error)?.message ?? "Request failed" }
1302
+ : m,
1303
+ ),
1304
+ );
1305
+ } finally {
1306
+ if (!abort.aborted) setSbxChatLoading(false);
1307
+ }
1308
+ }, [
1309
+ sbxChatInput,
1310
+ sbxChatLoading,
1311
+ sbxChatMessages,
1312
+ clientType,
1313
+ reactFiles,
1314
+ clientCode,
1315
+ serverCode,
1316
+ ]);
1317
+
1318
+ // ── Sandbox handlers ─────────────────────────────────────────────
599
1319
 
600
1320
  const startServer = async () => {
601
1321
  if (serverStarting) return;
@@ -856,27 +1576,40 @@ export default function CodeRunnerModal() {
856
1576
  type="button"
857
1577
  onMouseDown={(e) => e.stopPropagation()}
858
1578
  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);
1579
+ if (activeScriptId) {
1580
+ void overwriteScriptSnippet();
1581
+ } else {
1582
+ if (!code.trim()) return;
1583
+ setSnippetName("Runner snippet");
1584
+ setNaming(true);
1585
+ setTimeout(() => {
1586
+ nameInputRef.current?.focus();
1587
+ nameInputRef.current?.select();
1588
+ }, 30);
1589
+ }
866
1590
  }}
867
- disabled={!code.trim()}
1591
+ disabled={saving || (!activeScriptId && !code.trim())}
868
1592
  className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-colors shrink-0 ${
869
1593
  saved
870
1594
  ? "bg-cyan-600/30 text-cyan-300"
871
1595
  : "bg-slate-700/60 hover:bg-slate-600/60 text-slate-400 hover:text-slate-200"
872
1596
  } disabled:opacity-40`}
873
- title="Save to question context"
1597
+ title={
1598
+ activeScriptId
1599
+ ? "Overwrite saved script"
1600
+ : "Save to question context"
1601
+ }
874
1602
  >
875
1603
  {saved ? (
876
1604
  <>
877
1605
  <Check className="w-3 h-3" />
878
1606
  Saved
879
1607
  </>
1608
+ ) : saving ? (
1609
+ <>
1610
+ <Loader2 className="w-3 h-3 animate-spin" />
1611
+ Saving…
1612
+ </>
880
1613
  ) : (
881
1614
  <>
882
1615
  <Save className="w-3 h-3" />
@@ -886,6 +1619,28 @@ export default function CodeRunnerModal() {
886
1619
  </button>
887
1620
  )}
888
1621
 
1622
+ {/* Save As — only shown when a script is already saved */}
1623
+ {currentQuestion && !naming && activeScriptId && (
1624
+ <button
1625
+ type="button"
1626
+ onMouseDown={(e) => e.stopPropagation()}
1627
+ onClick={() => {
1628
+ if (!code.trim()) return;
1629
+ setSnippetName("Runner snippet");
1630
+ setNaming(true);
1631
+ setTimeout(() => {
1632
+ nameInputRef.current?.focus();
1633
+ nameInputRef.current?.select();
1634
+ }, 30);
1635
+ }}
1636
+ disabled={!code.trim() || saving}
1637
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium text-slate-400 hover:text-cyan-300 hover:bg-cyan-600/10 transition-colors shrink-0 disabled:opacity-40"
1638
+ title="Save as a new script"
1639
+ >
1640
+ <Save className="w-3 h-3" /> Save As
1641
+ </button>
1642
+ )}
1643
+
889
1644
  {/* Inline name input */}
890
1645
  {currentQuestion && naming && (
891
1646
  <div
@@ -903,13 +1658,14 @@ export default function CodeRunnerModal() {
903
1658
  setSaving(true);
904
1659
  setNaming(false);
905
1660
  try {
906
- await saveCodeSnippetToQuestion(
1661
+ const cf = await saveCodeSnippetToQuestion(
907
1662
  currentQuestion.id,
908
1663
  code,
909
1664
  lang,
910
1665
  label,
911
1666
  "user",
912
1667
  );
1668
+ setActiveScriptId(cf.id);
913
1669
  setSaved(true);
914
1670
  setTimeout(() => setSaved(false), 2000);
915
1671
  } finally {
@@ -929,13 +1685,14 @@ export default function CodeRunnerModal() {
929
1685
  setSaving(true);
930
1686
  setNaming(false);
931
1687
  try {
932
- await saveCodeSnippetToQuestion(
1688
+ const cf = await saveCodeSnippetToQuestion(
933
1689
  currentQuestion.id,
934
1690
  code,
935
1691
  lang,
936
1692
  label,
937
1693
  "user",
938
1694
  );
1695
+ setActiveScriptId(cf.id);
939
1696
  setSaved(true);
940
1697
  setTimeout(() => setSaved(false), 2000);
941
1698
  } finally {
@@ -1383,11 +2140,43 @@ export default function CodeRunnerModal() {
1383
2140
  className="flex flex-col min-w-0 overflow-hidden"
1384
2141
  style={{ flex: "1 1 0" }}
1385
2142
  >
2143
+ {/* Client panel header */}
1386
2144
  <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
2145
  <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">
2146
+ <span className="text-[10px] uppercase tracking-wider text-slate-500 font-medium">
1389
2147
  Client
1390
2148
  </span>
2149
+ {/* Client type selector: JS / React / Next */}
2150
+ <div className="flex items-center rounded overflow-hidden border border-slate-700 text-[9px] ml-1 shrink-0">
2151
+ {(
2152
+ [
2153
+ "script",
2154
+ "react",
2155
+ "nextjs",
2156
+ "module-federation",
2157
+ ] as const
2158
+ ).map((ct) => (
2159
+ <button
2160
+ key={ct}
2161
+ type="button"
2162
+ onClick={() => handleClientTypeChange(ct)}
2163
+ className={`px-1.5 py-0.5 transition-colors ${
2164
+ clientType === ct
2165
+ ? "bg-slate-600 text-slate-200"
2166
+ : "text-slate-500 hover:text-slate-400"
2167
+ }`}
2168
+ >
2169
+ {ct === "script"
2170
+ ? "JS"
2171
+ : ct === "react"
2172
+ ? "React"
2173
+ : ct === "nextjs"
2174
+ ? "Next"
2175
+ : "Webpack"}
2176
+ </button>
2177
+ ))}
2178
+ </div>
2179
+ <div className="flex-1" />
1391
2180
  <button
1392
2181
  type="button"
1393
2182
  onClick={() => {
@@ -1399,63 +2188,807 @@ export default function CodeRunnerModal() {
1399
2188
  >
1400
2189
  <ChevronRight className="w-3 h-3" />
1401
2190
  </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>
2191
+ {/* Script mode: URL + lang toggle + Run */}
2192
+ {clientType === "script" && (
2193
+ <>
2194
+ {sandboxUrl && (
2195
+ <span
2196
+ className="text-[9px] font-mono text-slate-600 truncate max-w-[90px]"
2197
+ title={sandboxUrl}
2198
+ >
2199
+ {sandboxUrl}
2200
+ </span>
2201
+ )}
2202
+ <div className="flex items-center gap-0.5">
2203
+ {LANG_OPTIONS.map((l) => (
2204
+ <button
2205
+ key={l}
2206
+ type="button"
2207
+ onClick={() => setClientLang(l)}
2208
+ className={`px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wider font-mono transition-colors ${
2209
+ clientLang === l
2210
+ ? "bg-violet-600/30 text-violet-300"
2211
+ : "text-slate-600 hover:text-slate-300"
2212
+ }`}
2213
+ >
2214
+ {l === "typescript" ? "TS" : "JS"}
2215
+ </button>
2216
+ ))}
2217
+ </div>
2218
+ <button
2219
+ type="button"
2220
+ onClick={() => void runClient()}
2221
+ disabled={clientRunning || !serverRunning}
2222
+ 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"
2223
+ title={
2224
+ serverRunning
2225
+ ? "Run client (Ctrl+Enter in editor)"
2226
+ : "Start the server first"
2227
+ }
2228
+ >
2229
+ {clientRunning ? (
2230
+ <Loader2 className="w-3 h-3 animate-spin" />
2231
+ ) : (
2232
+ <Play className="w-3 h-3" />
2233
+ )}
2234
+ Run
2235
+ </button>
2236
+ </>
1409
2237
  )}
1410
- <div className="flex items-center gap-0.5">
1411
- {LANG_OPTIONS.map((l) => (
2238
+ {/* React/Next mode: optional URL + Preview button + edit/preview toggle for Next */}
2239
+ {(clientType === "react" ||
2240
+ clientType === "nextjs" ||
2241
+ clientType === "module-federation") && (
2242
+ <>
2243
+ {clientType !== "module-federation" && sandboxUrl && (
2244
+ <span
2245
+ className="text-[9px] font-mono text-slate-600 truncate max-w-[80px]"
2246
+ title={sandboxUrl}
2247
+ >
2248
+ {sandboxUrl}
2249
+ </span>
2250
+ )}
2251
+ {/* React mode: simple preview button */}
2252
+ {clientType === "react" && (
2253
+ <button
2254
+ type="button"
2255
+ onClick={() => refreshPreview()}
2256
+ 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 transition-colors shrink-0"
2257
+ title="Render preview"
2258
+ >
2259
+ <Eye className="w-3 h-3" />
2260
+ Preview
2261
+ </button>
2262
+ )}
2263
+ {/* Next.js mode: start real server OR edit/preview toggle */}
2264
+ {clientType === "nextjs" && (
2265
+ <>
2266
+ {!nxSandboxUrl ? (
2267
+ <button
2268
+ type="button"
2269
+ onClick={() => void startNextjsServer()}
2270
+ disabled={nxStarting}
2271
+ 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-50 transition-colors shrink-0"
2272
+ title="Start real Next.js dev server"
2273
+ >
2274
+ {nxStarting ? (
2275
+ <Loader2 className="w-3 h-3 animate-spin" />
2276
+ ) : (
2277
+ <Play className="w-3 h-3" />
2278
+ )}
2279
+ {nxStarting ? "Starting…" : "Run Next.js"}
2280
+ </button>
2281
+ ) : (
2282
+ <div className="flex items-center rounded overflow-hidden border border-slate-700/50 text-[9px] shrink-0">
2283
+ <button
2284
+ type="button"
2285
+ onClick={() => setReactClientTab("edit")}
2286
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2287
+ reactClientTab === "edit"
2288
+ ? "bg-slate-700 text-slate-200"
2289
+ : "text-slate-500 hover:text-slate-400"
2290
+ }`}
2291
+ title="Edit code"
2292
+ >
2293
+ <Code2 className="w-2.5 h-2.5" />
2294
+ </button>
2295
+ <button
2296
+ type="button"
2297
+ onClick={() => setReactClientTab("preview")}
2298
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2299
+ reactClientTab === "preview"
2300
+ ? "bg-slate-700 text-slate-200"
2301
+ : "text-slate-500 hover:text-slate-400"
2302
+ }`}
2303
+ title="Live preview"
2304
+ >
2305
+ <Eye className="w-2.5 h-2.5" />
2306
+ </button>
2307
+ </div>
2308
+ )}
2309
+ </>
2310
+ )}
2311
+ {clientType === "module-federation" && (
2312
+ <>
2313
+ {mfSandboxId && mfHostUrl && (
2314
+ <span
2315
+ className="text-[9px] font-mono text-slate-600 truncate max-w-[110px]"
2316
+ title={mfHostUrl}
2317
+ >
2318
+ {mfHostUrl.replace(/^https?:\/\//, "")}
2319
+ </span>
2320
+ )}
2321
+ {!mfSandboxId ? (
2322
+ <button
2323
+ type="button"
2324
+ onClick={() => void startModuleFederationServer()}
2325
+ disabled={mfStarting}
2326
+ 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-50 transition-colors shrink-0"
2327
+ title="Start real webpack module federation dev servers"
2328
+ >
2329
+ {mfStarting ? (
2330
+ <Loader2 className="w-3 h-3 animate-spin" />
2331
+ ) : (
2332
+ <Play className="w-3 h-3" />
2333
+ )}
2334
+ {mfStarting ? "Starting…" : "Run Webpack"}
2335
+ </button>
2336
+ ) : (
2337
+ <>
2338
+ <div className="flex items-center rounded overflow-hidden border border-slate-700/50 text-[9px] shrink-0">
2339
+ <button
2340
+ type="button"
2341
+ onClick={() => setReactClientTab("edit")}
2342
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2343
+ reactClientTab === "edit"
2344
+ ? "bg-slate-700 text-slate-200"
2345
+ : "text-slate-500 hover:text-slate-400"
2346
+ }`}
2347
+ title="Edit code"
2348
+ >
2349
+ <Code2 className="w-2.5 h-2.5" />
2350
+ </button>
2351
+ <button
2352
+ type="button"
2353
+ onClick={() => setReactClientTab("preview")}
2354
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2355
+ reactClientTab === "preview"
2356
+ ? "bg-slate-700 text-slate-200"
2357
+ : "text-slate-500 hover:text-slate-400"
2358
+ }`}
2359
+ title="Live preview"
2360
+ >
2361
+ <Eye className="w-2.5 h-2.5" />
2362
+ </button>
2363
+ </div>
2364
+ <button
2365
+ type="button"
2366
+ onClick={() =>
2367
+ void stopModuleFederationServer()
2368
+ }
2369
+ className="p-0.5 rounded text-slate-600 hover:text-red-400 transition-colors shrink-0"
2370
+ title="Stop webpack lab"
2371
+ >
2372
+ <StopCircle className="w-3 h-3" />
2373
+ </button>
2374
+ </>
2375
+ )}
2376
+ </>
2377
+ )}
2378
+ </>
2379
+ )}
2380
+ </div>
2381
+
2382
+ {/* File tabs row (React only — Next.js uses the tree sidebar) */}
2383
+ {clientType === "react" && (
2384
+ <div className="flex items-center gap-0.5 px-2 py-1 bg-slate-800/40 border-b border-slate-700 shrink-0 overflow-x-auto">
2385
+ {Object.keys(reactFiles).map((fname) => (
1412
2386
  <button
1413
- key={l}
2387
+ key={fname}
1414
2388
  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"
2389
+ onClick={() => {
2390
+ setReactActiveFile(fname);
2391
+ setReactClientTab("edit");
2392
+ }}
2393
+ className={`flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-mono whitespace-nowrap transition-colors ${
2394
+ fname === reactActiveFile && reactClientTab === "edit"
2395
+ ? "bg-slate-900 text-slate-200 border border-slate-600"
2396
+ : "text-slate-500 hover:text-slate-300 hover:bg-slate-800/50"
1420
2397
  }`}
1421
2398
  >
1422
- {l === "typescript" ? "TS" : "JS"}
2399
+ {fname.includes("/") ? fname.split("/").pop() : fname}
2400
+ <span
2401
+ role="button"
2402
+ onClick={(e) => {
2403
+ e.stopPropagation();
2404
+ if (Object.keys(reactFiles).length <= 1) return;
2405
+ const remaining = Object.keys(reactFiles).filter(
2406
+ (f) => f !== fname,
2407
+ );
2408
+ setReactFiles((prev) => {
2409
+ const next = { ...prev };
2410
+ delete next[fname];
2411
+ return next;
2412
+ });
2413
+ if (reactActiveFile === fname)
2414
+ setReactActiveFile(remaining[0] ?? "");
2415
+ }}
2416
+ className="w-3 h-3 flex items-center justify-center text-slate-600 hover:text-red-400 rounded transition-colors"
2417
+ title="Delete file"
2418
+ >
2419
+ <X className="w-2.5 h-2.5" />
2420
+ </span>
1423
2421
  </button>
1424
2422
  ))}
2423
+ {/* Add new file */}
2424
+ {reactAddingFile ? (
2425
+ <input
2426
+ autoFocus
2427
+ value={reactNewFileName}
2428
+ onChange={(e) => setReactNewFileName(e.target.value)}
2429
+ onBlur={() => {
2430
+ setReactAddingFile(false);
2431
+ setReactNewFileName("");
2432
+ }}
2433
+ onKeyDown={(e) => {
2434
+ if (e.key === "Enter") {
2435
+ e.preventDefault();
2436
+ const name = reactNewFileName.trim();
2437
+ if (name && !reactFiles[name]) {
2438
+ setReactFiles((prev) => ({
2439
+ ...prev,
2440
+ [name]: newFileContent(name),
2441
+ }));
2442
+ setReactActiveFile(name);
2443
+ setReactClientTab("edit");
2444
+ }
2445
+ setReactAddingFile(false);
2446
+ setReactNewFileName("");
2447
+ } else if (e.key === "Escape") {
2448
+ setReactAddingFile(false);
2449
+ setReactNewFileName("");
2450
+ }
2451
+ }}
2452
+ placeholder="filename.tsx"
2453
+ className="w-28 bg-slate-900 border border-cyan-600/50 rounded px-1.5 py-0.5 text-[10px] font-mono text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500"
2454
+ />
2455
+ ) : (
2456
+ <button
2457
+ type="button"
2458
+ onClick={() => setReactAddingFile(true)}
2459
+ className="p-0.5 rounded text-slate-600 hover:text-cyan-400 transition-colors shrink-0"
2460
+ title="New file"
2461
+ >
2462
+ <FilePlus className="w-3 h-3" />
2463
+ </button>
2464
+ )}
2465
+ {/* Edit / Preview tab toggle */}
2466
+ <div className="ml-auto flex items-center rounded overflow-hidden border border-slate-700/50 text-[9px] shrink-0">
2467
+ <button
2468
+ type="button"
2469
+ onClick={() => setReactClientTab("edit")}
2470
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2471
+ reactClientTab === "edit"
2472
+ ? "bg-slate-700 text-slate-200"
2473
+ : "text-slate-500 hover:text-slate-400"
2474
+ }`}
2475
+ title="Edit code"
2476
+ >
2477
+ <Code2 className="w-2.5 h-2.5" />
2478
+ </button>
2479
+ <button
2480
+ type="button"
2481
+ onClick={() => {
2482
+ if (!reactPreviewSrc) refreshPreview();
2483
+ else setReactClientTab("preview");
2484
+ }}
2485
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2486
+ reactClientTab === "preview"
2487
+ ? "bg-slate-700 text-slate-200"
2488
+ : "text-slate-500 hover:text-slate-400"
2489
+ }`}
2490
+ title="Live preview"
2491
+ >
2492
+ <Eye className="w-2.5 h-2.5" />
2493
+ </button>
2494
+ </div>
1425
2495
  </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
- }
2496
+ )}
2497
+
2498
+ {/* Client body */}
2499
+ <div
2500
+ className={`flex-1 min-h-0 ${clientType === "nextjs" || clientType === "module-federation" ? "flex flex-row" : "relative"}`}
2501
+ >
2502
+ {/* ── Next.js VS Code-style file tree sidebar ── */}
2503
+ {(clientType === "nextjs" ||
2504
+ clientType === "module-federation") && (
2505
+ <div className="w-36 shrink-0 flex flex-col border-r border-slate-700 bg-slate-900/60 overflow-y-auto">
2506
+ {/* Sidebar header */}
2507
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-700/60">
2508
+ <span className="text-[9px] uppercase tracking-widest text-slate-500 font-semibold select-none">
2509
+ Explorer
2510
+ </span>
2511
+ {/* Add file button */}
2512
+ {reactAddingFile ? (
2513
+ <input
2514
+ autoFocus
2515
+ value={reactNewFileName}
2516
+ onChange={(e) =>
2517
+ setReactNewFileName(e.target.value)
2518
+ }
2519
+ onBlur={() => {
2520
+ setReactAddingFile(false);
2521
+ setReactNewFileName("");
2522
+ }}
2523
+ onKeyDown={(e) => {
2524
+ if (e.key === "Enter") {
2525
+ e.preventDefault();
2526
+ const name = reactNewFileName.trim();
2527
+ if (name && !reactFiles[name]) {
2528
+ setReactFiles((prev) => ({
2529
+ ...prev,
2530
+ [name]: newFileContent(name),
2531
+ }));
2532
+ setReactActiveFile(name);
2533
+ setReactClientTab("edit");
2534
+ }
2535
+ setReactAddingFile(false);
2536
+ setReactNewFileName("");
2537
+ } else if (e.key === "Escape") {
2538
+ setReactAddingFile(false);
2539
+ setReactNewFileName("");
2540
+ }
2541
+ }}
2542
+ placeholder={
2543
+ clientType === "module-federation"
2544
+ ? "apps/orders/src/App.jsx"
2545
+ : "app/new.tsx"
2546
+ }
2547
+ className="w-full bg-slate-800 border border-cyan-600/50 rounded px-1 py-0.5 text-[9px] font-mono text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500"
2548
+ />
2549
+ ) : (
2550
+ <button
2551
+ type="button"
2552
+ onClick={() => setReactAddingFile(true)}
2553
+ className="p-0.5 rounded text-slate-600 hover:text-cyan-400 transition-colors"
2554
+ title={
2555
+ clientType === "module-federation"
2556
+ ? "New file (use paths like apps/orders/src/App.jsx)"
2557
+ : "New file (use paths like app/dashboard/page.tsx)"
2558
+ }
2559
+ >
2560
+ <FilePlus className="w-3 h-3" />
2561
+ </button>
2562
+ )}
2563
+ </div>
2564
+ {/* Tree nodes */}
2565
+ <div className="flex-1 py-1">
2566
+ {(() => {
2567
+ const tree = buildFileTree(Object.keys(reactFiles));
2568
+
2569
+ const fileIcon = (name: string) => {
2570
+ if (name.endsWith(".tsx") || name.endsWith(".jsx"))
2571
+ return (
2572
+ <span className="text-cyan-400 mr-1 text-[9px]">
2573
+
2574
+ </span>
2575
+ );
2576
+ if (name.endsWith(".ts") || name.endsWith(".js"))
2577
+ return (
2578
+ <span className="text-yellow-400 mr-1 text-[9px]">
2579
+ JS
2580
+ </span>
2581
+ );
2582
+ return (
2583
+ <span className="text-slate-500 mr-1 text-[9px]">
2584
+ f
2585
+ </span>
2586
+ );
2587
+ };
2588
+
2589
+ const renderFile = (path: string, indent = 0) => (
2590
+ <div key={path} className="group flex items-center">
2591
+ <button
2592
+ type="button"
2593
+ onClick={() => {
2594
+ setReactActiveFile(path);
2595
+ setReactClientTab("edit");
2596
+ }}
2597
+ style={{ paddingLeft: `${8 + indent * 10}px` }}
2598
+ className={`flex-1 flex items-center gap-0.5 py-0.5 pr-1 text-left text-[10px] font-mono truncate transition-colors ${
2599
+ path === reactActiveFile &&
2600
+ reactClientTab === "edit"
2601
+ ? "bg-slate-700 text-slate-100"
2602
+ : "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
2603
+ }`}
2604
+ title={path}
2605
+ >
2606
+ {fileIcon(path.split("/").pop() ?? path)}
2607
+ <span className="truncate">
2608
+ {path.split("/").pop()}
2609
+ </span>
2610
+ </button>
2611
+ <button
2612
+ type="button"
2613
+ onClick={() => {
2614
+ if (Object.keys(reactFiles).length <= 1)
2615
+ return;
2616
+ const remaining = Object.keys(
2617
+ reactFiles,
2618
+ ).filter((f) => f !== path);
2619
+ setReactFiles((prev) => {
2620
+ const next = { ...prev };
2621
+ delete next[path];
2622
+ return next;
2623
+ });
2624
+ if (reactActiveFile === path)
2625
+ setReactActiveFile(remaining[0] ?? "");
2626
+ }}
2627
+ className="opacity-0 group-hover:opacity-100 p-0.5 mr-1 rounded text-slate-600 hover:text-red-400 transition-all shrink-0"
2628
+ title="Delete file"
2629
+ >
2630
+ <X className="w-2.5 h-2.5" />
2631
+ </button>
2632
+ </div>
2633
+ );
2634
+
2635
+ const renderNode = (
2636
+ node: FileTreeNode,
2637
+ indent = 0,
2638
+ ): React.ReactNode => {
2639
+ if (!node.path) {
2640
+ return (
2641
+ <>
2642
+ {node.children
2643
+ .sort((a, b) =>
2644
+ a.name.localeCompare(b.name),
2645
+ )
2646
+ .map((child) => renderNode(child, 0))}
2647
+ {node.files
2648
+ .sort((a, b) => a.localeCompare(b))
2649
+ .map((path) => renderFile(path, 0))}
2650
+ </>
2651
+ );
2652
+ }
2653
+
2654
+ const isOpen = !collapsedFolders.has(node.path);
2655
+
2656
+ return (
2657
+ <div key={node.path}>
2658
+ <button
2659
+ type="button"
2660
+ onClick={() =>
2661
+ setCollapsedFolders((prev) => {
2662
+ const next = new Set(prev);
2663
+ if (next.has(node.path)) {
2664
+ next.delete(node.path);
2665
+ } else {
2666
+ next.add(node.path);
2667
+ }
2668
+ return next;
2669
+ })
2670
+ }
2671
+ style={{
2672
+ paddingLeft: `${8 + indent * 10}px`,
2673
+ }}
2674
+ className="w-full flex items-center gap-0.5 pr-2 py-0.5 text-left text-[10px] font-mono text-slate-300 hover:bg-slate-800 transition-colors select-none"
2675
+ >
2676
+ {isOpen ? (
2677
+ <ChevronDown className="w-2.5 h-2.5 shrink-0 text-slate-500" />
2678
+ ) : (
2679
+ <ChevronRight className="w-2.5 h-2.5 shrink-0 text-slate-500" />
2680
+ )}
2681
+ <span className="text-yellow-300/80 mr-0.5">
2682
+ 📁
2683
+ </span>
2684
+ <span className="truncate">{node.name}/</span>
2685
+ </button>
2686
+ {isOpen && (
2687
+ <div>
2688
+ {node.children
2689
+ .sort((a, b) =>
2690
+ a.name.localeCompare(b.name),
2691
+ )
2692
+ .map((child) =>
2693
+ renderNode(child, indent + 1),
2694
+ )}
2695
+ {node.files
2696
+ .sort((a, b) => a.localeCompare(b))
2697
+ .map((path) =>
2698
+ renderFile(path, indent + 1),
2699
+ )}
2700
+ </div>
2701
+ )}
2702
+ </div>
2703
+ );
2704
+ };
2705
+
2706
+ return renderNode(tree);
2707
+ })()}
2708
+ </div>
2709
+ </div>
2710
+ )}
2711
+
2712
+ {/* ── Editor / Preview area ── */}
2713
+ <div
2714
+ className={`${clientType === "nextjs" || clientType === "module-federation" ? "flex-1 min-w-0 relative" : "absolute inset-0"}`}
1436
2715
  >
1437
- {clientRunning ? (
1438
- <Loader2 className="w-3 h-3 animate-spin" />
2716
+ {clientType === "script" ? (
2717
+ <SyntaxEditor
2718
+ value={clientCode}
2719
+ onChange={setClientCode}
2720
+ onCtrlEnter={() => {
2721
+ if (serverRunning) void runClient();
2722
+ }}
2723
+ language={clientLang}
2724
+ fontSize="12px"
2725
+ focusRingClass="ring-cyan-500/30"
2726
+ placeholder={
2727
+ "// SANDBOX_URL is injected automatically\n// Start the server first, then Ctrl+Enter to run"
2728
+ }
2729
+ />
2730
+ ) : reactClientTab === "edit" ? (
2731
+ <SyntaxEditor
2732
+ key={reactActiveFile}
2733
+ value={reactFiles[reactActiveFile] ?? ""}
2734
+ onChange={(val) =>
2735
+ setReactFiles((prev) => ({
2736
+ ...prev,
2737
+ [reactActiveFile]: val,
2738
+ }))
2739
+ }
2740
+ language={
2741
+ reactActiveFile.endsWith(".ts") ||
2742
+ reactActiveFile.endsWith(".tsx")
2743
+ ? "typescript"
2744
+ : "javascript"
2745
+ }
2746
+ fontSize="12px"
2747
+ focusRingClass="ring-cyan-500/30"
2748
+ placeholder={`// ${reactActiveFile}\n`}
2749
+ />
1439
2750
  ) : (
1440
- <Play className="w-3 h-3" />
2751
+ <div className="w-full h-full flex flex-col">
2752
+ {clientType === "nextjs" && (
2753
+ <div className="flex items-center gap-1 px-2 py-1 bg-slate-800 border-b border-slate-700 shrink-0">
2754
+ <button
2755
+ type="button"
2756
+ disabled={reactNavIndex <= 0}
2757
+ onClick={() => {
2758
+ const idx = reactNavIndex - 1;
2759
+ const path = reactNavHistory[idx] ?? "/";
2760
+ setReactNavIndex(idx);
2761
+ setReactPreviewPath(path);
2762
+ setReactNavInput(path);
2763
+ if (nxSandboxUrl) {
2764
+ if (nxIframeRef.current)
2765
+ nxIframeRef.current.src =
2766
+ nxSandboxUrl + path;
2767
+ } else {
2768
+ refreshPreview(path);
2769
+ }
2770
+ }}
2771
+ className="p-0.5 rounded text-slate-500 hover:text-slate-200 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
2772
+ title="Back"
2773
+ >
2774
+ <ChevronLeft className="w-3.5 h-3.5" />
2775
+ </button>
2776
+ <button
2777
+ type="button"
2778
+ disabled={
2779
+ reactNavIndex >= reactNavHistory.length - 1
2780
+ }
2781
+ onClick={() => {
2782
+ const idx = reactNavIndex + 1;
2783
+ const path = reactNavHistory[idx] ?? "/";
2784
+ setReactNavIndex(idx);
2785
+ setReactPreviewPath(path);
2786
+ setReactNavInput(path);
2787
+ if (nxSandboxUrl) {
2788
+ if (nxIframeRef.current)
2789
+ nxIframeRef.current.src =
2790
+ nxSandboxUrl + path;
2791
+ } else {
2792
+ refreshPreview(path);
2793
+ }
2794
+ }}
2795
+ className="p-0.5 rounded text-slate-500 hover:text-slate-200 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
2796
+ title="Forward"
2797
+ >
2798
+ <ChevronRight className="w-3.5 h-3.5" />
2799
+ </button>
2800
+ <button
2801
+ type="button"
2802
+ onClick={() => {
2803
+ if (nxSandboxUrl && nxIframeRef.current) {
2804
+ nxIframeRef.current.src =
2805
+ nxIframeRef.current.src;
2806
+ } else {
2807
+ refreshPreview();
2808
+ }
2809
+ }}
2810
+ className="p-0.5 rounded text-slate-500 hover:text-slate-200 transition-colors shrink-0"
2811
+ title="Refresh"
2812
+ >
2813
+ <svg
2814
+ className="w-3 h-3"
2815
+ viewBox="0 0 16 16"
2816
+ fill="currentColor"
2817
+ >
2818
+ <path d="M13.65 2.35A8 8 0 1 0 15 8h-2a6 6 0 1 1-1.1-3.48L10 6h5V1l-1.35 1.35z" />
2819
+ </svg>
2820
+ </button>
2821
+ <form
2822
+ className="flex-1 flex items-center gap-1 bg-slate-900 border border-slate-600 rounded px-2 py-0.5 focus-within:border-blue-500/60 transition-colors"
2823
+ onSubmit={(e) => {
2824
+ e.preventDefault();
2825
+ const path = reactNavInput.startsWith("/")
2826
+ ? reactNavInput
2827
+ : "/" + reactNavInput;
2828
+ setReactPreviewPath(path);
2829
+ setReactNavHistory((prev) => [
2830
+ ...prev.slice(0, reactNavIndex + 1),
2831
+ path,
2832
+ ]);
2833
+ setReactNavIndex((i) => i + 1);
2834
+ if (nxSandboxUrl) {
2835
+ if (nxIframeRef.current)
2836
+ nxIframeRef.current.src =
2837
+ nxSandboxUrl + path;
2838
+ } else {
2839
+ navigatePreview(path);
2840
+ }
2841
+ }}
2842
+ >
2843
+ <span className="text-slate-600 text-[9px] font-mono select-none shrink-0">
2844
+ {nxSandboxUrl
2845
+ ? `localhost:${nxSandboxUrl.split(":").pop()}`
2846
+ : "localhost:3000"}
2847
+ </span>
2848
+ <input
2849
+ value={reactNavInput}
2850
+ onChange={(e) =>
2851
+ setReactNavInput(e.target.value)
2852
+ }
2853
+ onFocus={(e) => e.target.select()}
2854
+ className="flex-1 bg-transparent text-[11px] font-mono text-slate-200 outline-none placeholder-slate-600 min-w-0"
2855
+ placeholder="/"
2856
+ spellCheck={false}
2857
+ />
2858
+ </form>
2859
+ {nxSandboxUrl ? (
2860
+ <span className="text-[9px] font-mono text-green-400 shrink-0">
2861
+ ● live
2862
+ </span>
2863
+ ) : (
2864
+ (() => {
2865
+ const resolved = resolveNextjsEntry(
2866
+ reactFiles,
2867
+ reactPreviewPath,
2868
+ );
2869
+ return resolved ? (
2870
+ <span
2871
+ className="text-[9px] font-mono text-slate-600 truncate max-w-[100px] shrink-0"
2872
+ title={resolved}
2873
+ >
2874
+ → {resolved}
2875
+ </span>
2876
+ ) : (
2877
+ <span className="text-[9px] font-mono text-red-400 shrink-0">
2878
+ 404
2879
+ </span>
2880
+ );
2881
+ })()
2882
+ )}
2883
+ </div>
2884
+ )}
2885
+ {clientType === "module-federation" && (
2886
+ <div className="flex items-center gap-1 px-2 py-1 bg-slate-800 border-b border-slate-700 shrink-0 overflow-x-auto">
2887
+ {Object.entries(mfAppUrls).map(([name, url]) => (
2888
+ <button
2889
+ key={name}
2890
+ type="button"
2891
+ onClick={() => setMfPreviewApp(name)}
2892
+ className={`px-2 py-0.5 rounded text-[10px] font-mono transition-colors shrink-0 ${
2893
+ mfPreviewApp === name
2894
+ ? "bg-slate-700 text-slate-100"
2895
+ : "text-slate-500 hover:text-slate-300 hover:bg-slate-700/40"
2896
+ }`}
2897
+ title={url}
2898
+ >
2899
+ {name}
2900
+ </button>
2901
+ ))}
2902
+ <div className="ml-auto text-[9px] font-mono text-slate-600 shrink-0">
2903
+ {mfAppUrls[mfPreviewApp] ??
2904
+ mfHostUrl ??
2905
+ "Start webpack to preview"}
2906
+ </div>
2907
+ </div>
2908
+ )}
2909
+ {((clientType === "module-federation" && mfError) ||
2910
+ (clientType !== "module-federation" && nxError)) && (
2911
+ <div className="text-[10px] text-red-400 bg-red-950/40 border-b border-red-800 px-3 py-1.5 shrink-0 font-mono">
2912
+ {clientType === "module-federation"
2913
+ ? mfError
2914
+ : nxError}
2915
+ </div>
2916
+ )}
2917
+ {(nxStarting || mfStarting) && (
2918
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-400 text-sm bg-slate-950">
2919
+ <Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
2920
+ <p className="text-[12px]">
2921
+ {clientType === "module-federation"
2922
+ ? "Installing dependencies and starting webpack apps…"
2923
+ : "Starting Next.js dev server…"}
2924
+ </p>
2925
+ <p className="text-[10px] text-slate-600 max-w-md text-center px-4">
2926
+ {clientType === "module-federation"
2927
+ ? "The first run can take a little longer because npm install runs inside the lab sandbox."
2928
+ : "This takes ~10 seconds on the first run"}
2929
+ </p>
2930
+ </div>
2931
+ )}
2932
+ {!nxStarting &&
2933
+ clientType === "nextjs" &&
2934
+ nxSandboxUrl && (
2935
+ <iframe
2936
+ ref={nxIframeRef}
2937
+ src={nxSandboxUrl + reactPreviewPath}
2938
+ className="flex-1 min-h-0 w-full border-0 bg-white"
2939
+ title="Next.js Preview"
2940
+ onLoad={() => {
2941
+ try {
2942
+ const p =
2943
+ nxIframeRef.current?.contentWindow?.location
2944
+ .pathname;
2945
+ if (p) {
2946
+ setReactPreviewPath(p);
2947
+ setReactNavInput(p);
2948
+ }
2949
+ } catch {
2950
+ // cross-origin — ignore
2951
+ }
2952
+ }}
2953
+ />
2954
+ )}
2955
+ {!mfStarting &&
2956
+ clientType === "module-federation" &&
2957
+ mfSandboxId &&
2958
+ (mfAppUrls[mfPreviewApp] ?? mfHostUrl) && (
2959
+ <iframe
2960
+ src={mfAppUrls[mfPreviewApp] ?? mfHostUrl ?? ""}
2961
+ className="flex-1 min-h-0 w-full border-0 bg-white"
2962
+ title="Webpack Module Federation Preview"
2963
+ />
2964
+ )}
2965
+ {!mfStarting &&
2966
+ clientType === "module-federation" &&
2967
+ !mfSandboxId && (
2968
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-400 text-sm bg-slate-950 px-6 text-center">
2969
+ <Server className="w-8 h-8 text-cyan-400/70" />
2970
+ <p className="text-[12px]">
2971
+ Start the webpack lab to launch the host and
2972
+ remote dev servers.
2973
+ </p>
2974
+ <p className="text-[10px] text-slate-600 max-w-md">
2975
+ The preview lets you switch between the host and
2976
+ each remote so you can inspect their output
2977
+ independently.
2978
+ </p>
2979
+ </div>
2980
+ )}
2981
+ {!nxStarting && clientType === "react" && (
2982
+ <iframe
2983
+ srcDoc={reactPreviewSrc ?? ""}
2984
+ sandbox="allow-scripts"
2985
+ className="flex-1 min-h-0 w-full border-0 bg-white"
2986
+ title="React Preview"
2987
+ />
2988
+ )}
2989
+ </div>
1441
2990
  )}
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
- />
2991
+ </div>
1459
2992
  </div>
1460
2993
  </div>
1461
2994
  )}
@@ -1479,18 +3012,81 @@ export default function CodeRunnerModal() {
1479
3012
  className="bg-slate-950 flex flex-col overflow-hidden shrink-0 transition-[height]"
1480
3013
  style={{ height: outputCollapsed ? 0 : sbxOutputH }}
1481
3014
  >
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">
3015
+ {/* Tab bar */}
3016
+ <div className="flex items-center gap-0 px-1 bg-slate-900 border-b border-slate-800 shrink-0">
3017
+ <button
3018
+ type="button"
3019
+ onClick={() => setSbxBottomTab("output")}
3020
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider font-medium border-b-2 transition-colors ${
3021
+ sbxBottomTab === "output"
3022
+ ? "border-emerald-500 text-emerald-300"
3023
+ : "border-transparent text-slate-500 hover:text-slate-300"
3024
+ }`}
3025
+ >
3026
+ {(serverStarting || clientRunning) &&
3027
+ sbxBottomTab !== "output" ? (
3028
+ <Loader2 className="w-3 h-3 animate-spin" />
3029
+ ) : null}
1484
3030
  Output
1485
- </span>
1486
- {(serverStarting || clientRunning) && (
1487
- <Loader2 className="w-3 h-3 text-emerald-400 animate-spin" />
3031
+ </button>
3032
+ <button
3033
+ type="button"
3034
+ onClick={() => {
3035
+ setSbxBottomTab("chat");
3036
+ setTimeout(() => sbxChatInputRef.current?.focus(), 30);
3037
+ }}
3038
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider font-medium border-b-2 transition-colors ${
3039
+ sbxBottomTab === "chat"
3040
+ ? "border-violet-500 text-violet-300"
3041
+ : "border-transparent text-slate-500 hover:text-slate-300"
3042
+ }`}
3043
+ >
3044
+ <MessageSquare className="w-3 h-3" />
3045
+ Chat
3046
+ </button>
3047
+ <div className="flex-1" />
3048
+ {sbxBottomTab === "output" &&
3049
+ (serverStarting || clientRunning) && (
3050
+ <Loader2 className="w-3 h-3 text-emerald-400 animate-spin mr-1" />
3051
+ )}
3052
+ {sbxBottomTab === "output" && sandboxOutput.length > 0 && (
3053
+ <div className="flex items-center gap-1 mr-1">
3054
+ <button
3055
+ type="button"
3056
+ onClick={() =>
3057
+ navigator.clipboard.writeText(
3058
+ sandboxOutput.map((l) => l.text).join("\n"),
3059
+ )
3060
+ }
3061
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
3062
+ title="Copy output"
3063
+ >
3064
+ <Copy className="w-3 h-3" />
3065
+ </button>
3066
+ <button
3067
+ type="button"
3068
+ onClick={() => setSandboxOutput([])}
3069
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
3070
+ title="Clear output"
3071
+ >
3072
+ <Trash2 className="w-3 h-3" />
3073
+ </button>
3074
+ </div>
3075
+ )}
3076
+ {sbxBottomTab === "chat" && sbxChatMessages.length > 0 && (
3077
+ <button
3078
+ type="button"
3079
+ onClick={() => setSbxChatMessages([])}
3080
+ className="mr-2 text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
3081
+ >
3082
+ clear
3083
+ </button>
1488
3084
  )}
1489
3085
  <button
1490
3086
  type="button"
1491
- className="ml-auto p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
3087
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors mr-1"
1492
3088
  onClick={() => setOutputCollapsed((v) => !v)}
1493
- title={outputCollapsed ? "Expand output" : "Collapse output"}
3089
+ title={outputCollapsed ? "Expand" : "Collapse"}
1494
3090
  >
1495
3091
  {outputCollapsed ? (
1496
3092
  <ChevronUp className="w-3 h-3" />
@@ -1499,48 +3095,227 @@ export default function CodeRunnerModal() {
1499
3095
  )}
1500
3096
  </button>
1501
3097
  </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"
3098
+
3099
+ {/* Output tab */}
3100
+ {sbxBottomTab === "output" && (
3101
+ <div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[12px] leading-relaxed">
3102
+ {sandboxOutput.length === 0 &&
3103
+ !serverStarting &&
3104
+ !clientRunning && (
3105
+ <span className="text-slate-600">
3106
+ {clientType === "module-federation"
3107
+ ? "Run webpack to start the host and remotes"
3108
+ : "Start the server, then run the client"}
3109
+ </span>
3110
+ )}
3111
+ {sandboxOutput.map((line, i) => (
3112
+ <div key={i} className="flex items-start gap-2">
3113
+ <span
3114
+ className={`shrink-0 text-[9px] font-bold mt-0.5 w-7 text-right ${
3115
+ line.source === "server"
3116
+ ? "text-emerald-600"
3117
+ : line.source === "client"
3118
+ ? "text-cyan-600"
3119
+ : "text-slate-700"
3120
+ }`}
3121
+ >
3122
+ {line.source === "server"
3123
+ ? "srv"
1516
3124
  : 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
- }
3125
+ ? "cli"
3126
+ : "···"}
3127
+ </span>
3128
+ <span
3129
+ className={
3130
+ line.kind === "stderr"
3131
+ ? "text-red-400 whitespace-pre-wrap"
3132
+ : line.kind === "warn"
3133
+ ? "text-amber-400 whitespace-pre-wrap"
3134
+ : line.kind === "info"
3135
+ ? "text-slate-500 italic whitespace-pre-wrap"
3136
+ : "text-slate-200 whitespace-pre-wrap"
3137
+ }
3138
+ >
3139
+ {line.text}
3140
+ </span>
3141
+ </div>
3142
+ ))}
3143
+ <div ref={outputEndRef} />
3144
+ </div>
3145
+ )}
3146
+
3147
+ {/* Chat tab */}
3148
+ {sbxBottomTab === "chat" && (
3149
+ <>
3150
+ <div
3151
+ ref={sbxChatScrollRef}
3152
+ className="flex-1 overflow-y-auto px-3 py-2 space-y-3"
3153
+ >
3154
+ {sbxChatMessages.length === 0 && (
3155
+ <p className="text-xs text-slate-600 pt-1">
3156
+ Ask anything about your code —{" "}
3157
+ {clientType === "react" ||
3158
+ clientType === "nextjs" ||
3159
+ clientType === "module-federation" ? (
3160
+ <span className="text-slate-500">
3161
+ {clientType === "module-federation"
3162
+ ? '"Why is remoteEntry.js 404ing?"'
3163
+ : '"Why does my useEffect run twice?"'}
3164
+ </span>
3165
+ ) : (
3166
+ <span className="text-slate-500">
3167
+ "Why is fetch failing?"
3168
+ </span>
3169
+ )}
3170
+ </p>
3171
+ )}
3172
+ {sbxChatMessages.map((msg) => (
3173
+ <div
3174
+ key={msg.id}
3175
+ className={`flex flex-col gap-0.5 ${msg.role === "user" ? "items-end" : "items-start"}`}
3176
+ >
3177
+ <div
3178
+ className={`max-w-[85%] rounded-xl px-3 py-2 text-xs leading-5 ${
3179
+ msg.role === "user"
3180
+ ? "bg-violet-600/30 text-violet-100 whitespace-pre-wrap"
3181
+ : "bg-slate-800 text-slate-200 prose prose-invert prose-xs max-w-none"
3182
+ }`}
3183
+ >
3184
+ {msg.role === "user" ? (
3185
+ msg.content
3186
+ ) : msg.content ? (
3187
+ <ReactMarkdown
3188
+ remarkPlugins={[remarkGfm]}
3189
+ components={{
3190
+ code({ className, children, ...props }) {
3191
+ const isBlock =
3192
+ className?.startsWith("language-");
3193
+ return isBlock ? (
3194
+ <pre className="bg-slate-900/80 rounded p-2 overflow-x-auto my-1">
3195
+ <code
3196
+ className={`${className ?? ""} text-[11px]`}
3197
+ {...props}
3198
+ >
3199
+ {children}
3200
+ </code>
3201
+ </pre>
3202
+ ) : (
3203
+ <code
3204
+ className="bg-slate-900/60 px-1 rounded text-violet-300 text-[11px]"
3205
+ {...props}
3206
+ >
3207
+ {children}
3208
+ </code>
3209
+ );
3210
+ },
3211
+ p({ children }) {
3212
+ return (
3213
+ <p className="mb-1 last:mb-0">{children}</p>
3214
+ );
3215
+ },
3216
+ ul({ children }) {
3217
+ return (
3218
+ <ul className="list-disc list-inside mb-1 space-y-0.5">
3219
+ {children}
3220
+ </ul>
3221
+ );
3222
+ },
3223
+ ol({ children }) {
3224
+ return (
3225
+ <ol className="list-decimal list-inside mb-1 space-y-0.5">
3226
+ {children}
3227
+ </ol>
3228
+ );
3229
+ },
3230
+ h2({ children }) {
3231
+ return (
3232
+ <h2 className="text-xs font-semibold text-slate-200 mt-2 mb-0.5">
3233
+ {children}
3234
+ </h2>
3235
+ );
3236
+ },
3237
+ h3({ children }) {
3238
+ return (
3239
+ <h3 className="text-xs font-semibold text-slate-300 mt-1.5 mb-0.5">
3240
+ {children}
3241
+ </h3>
3242
+ );
3243
+ },
3244
+ }}
3245
+ >
3246
+ {msg.content}
3247
+ </ReactMarkdown>
3248
+ ) : (
3249
+ <span className="flex items-center gap-1.5 text-slate-500">
3250
+ <Loader2 className="w-3 h-3 animate-spin" />{" "}
3251
+ thinking…
3252
+ </span>
3253
+ )}
3254
+ </div>
3255
+ {msg.role === "assistant" && msg.content && (
3256
+ <button
3257
+ onClick={() => {
3258
+ void navigator.clipboard
3259
+ .writeText(msg.content)
3260
+ .then(() => {
3261
+ setSbxChatCopiedId(msg.id);
3262
+ setTimeout(
3263
+ () => setSbxChatCopiedId(null),
3264
+ 1800,
3265
+ );
3266
+ });
3267
+ }}
3268
+ className="flex items-center gap-1 text-[10px] text-slate-700 hover:text-slate-400 transition-colors px-1"
3269
+ title="Copy response"
3270
+ >
3271
+ {sbxChatCopiedId === msg.id ? (
3272
+ <>
3273
+ <ClipboardCheck className="w-3 h-3 text-emerald-400" />
3274
+ <span className="text-emerald-400">Copied</span>
3275
+ </>
3276
+ ) : (
3277
+ <>
3278
+ <Clipboard className="w-3 h-3" />
3279
+ <span>Copy</span>
3280
+ </>
3281
+ )}
3282
+ </button>
3283
+ )}
3284
+ </div>
3285
+ ))}
3286
+ </div>
3287
+ <div className="flex items-end gap-1.5 px-3 py-2 border-t border-slate-800 bg-slate-900/60 shrink-0">
3288
+ <textarea
3289
+ ref={sbxChatInputRef}
3290
+ rows={1}
3291
+ value={sbxChatInput}
3292
+ onChange={(e) => setSbxChatInput(e.target.value)}
3293
+ onKeyDown={(e) => {
3294
+ if (e.key === "Enter" && !e.shiftKey) {
3295
+ e.preventDefault();
3296
+ void handleSbxChatSend();
3297
+ }
3298
+ }}
3299
+ placeholder={`Ask about your ${clientType === "react" ? "React" : clientType === "nextjs" ? "Next.js" : clientType === "module-federation" ? "webpack module federation" : "sandbox"} code…`}
3300
+ disabled={sbxChatLoading}
3301
+ className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-600 outline-none resize-none disabled:opacity-50 max-h-20"
3302
+ />
3303
+ <button
3304
+ type="button"
3305
+ onClick={() => void handleSbxChatSend()}
3306
+ disabled={sbxChatLoading || !sbxChatInput.trim()}
3307
+ className="p-1 rounded text-slate-600 hover:text-violet-400 hover:bg-violet-500/10 disabled:opacity-40 transition-colors shrink-0"
3308
+ title="Send (Enter)"
1537
3309
  >
1538
- {line.text}
1539
- </span>
3310
+ {sbxChatLoading ? (
3311
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
3312
+ ) : (
3313
+ <Send className="w-3.5 h-3.5" />
3314
+ )}
3315
+ </button>
1540
3316
  </div>
1541
- ))}
1542
- <div ref={outputEndRef} />
1543
- </div>
3317
+ </>
3318
+ )}
1544
3319
  </div>
1545
3320
  </div>
1546
3321
  )}