create-interview-cockpit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) 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 +321 -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 +419 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1601 -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 +477 -0
  23. package/template/client/src/store.ts +219 -6
  24. package/template/client/src/types.ts +35 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/server/src/google-drive.ts +37 -3
  27. package/template/server/src/index.ts +693 -52
  28. package/template/server/src/infra-runner.ts +1104 -0
  29. package/template/server/src/storage.ts +13 -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,18 @@ 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
+ } from "../reactLab";
47
+ import {
48
+ startNextjsSandbox,
49
+ updateNextjsFiles,
50
+ stopNextjsSandbox,
51
+ } from "../api";
52
+ import ReactMarkdown from "react-markdown";
53
+ import remarkGfm from "remark-gfm";
35
54
 
36
55
  const MIN_W = 420;
37
56
  const MIN_H = 300;
@@ -189,6 +208,7 @@ export default function CodeRunnerModal() {
189
208
  runnerInitialCode,
190
209
  runnerInitialLanguage,
191
210
  runnerInitialSandbox,
211
+ runnerInitialFileId,
192
212
  currentQuestion,
193
213
  saveCodeSnippetToQuestion,
194
214
  overwriteContextFileContent,
@@ -206,6 +226,10 @@ export default function CodeRunnerModal() {
206
226
  const [saving, setSaving] = useState(false);
207
227
  const [naming, setNaming] = useState(false);
208
228
  const [snippetName, setSnippetName] = useState("");
229
+ /** Non-null when editing a previously saved script — Save overwrites, Save As creates new */
230
+ const [activeScriptId, setActiveScriptId] = useState<string | null>(
231
+ runnerInitialFileId ?? null,
232
+ );
209
233
 
210
234
  // ── Sandbox state ─────────────────────────────────────────
211
235
  const [mode, setMode] = useState<"script" | "sandbox">("script");
@@ -221,6 +245,37 @@ export default function CodeRunnerModal() {
221
245
  const [sandboxOutput, setSandboxOutput] = useState<OutputLine[]>([]);
222
246
  const [clientRunning, setClientRunning] = useState(false);
223
247
 
248
+ // ── React/Next.js client state ──────────────────────────────
249
+ const [clientType, setClientType] = useState<"script" | "react" | "nextjs">(
250
+ "script",
251
+ );
252
+ const [reactFiles, setReactFiles] = useState<Record<string, string>>({});
253
+ const [reactActiveFile, setReactActiveFile] = useState<string>("");
254
+ const [reactClientTab, setReactClientTab] = useState<"edit" | "preview">(
255
+ "edit",
256
+ );
257
+ const [reactPreviewSrc, setReactPreviewSrc] = useState<string | null>(null);
258
+ const [reactAddingFile, setReactAddingFile] = useState(false);
259
+ const [reactNewFileName, setReactNewFileName] = useState("");
260
+ // Folders that are collapsed in the Next.js file tree sidebar
261
+ const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
262
+ new Set(),
263
+ );
264
+ // Real Next.js dev-server state
265
+ const [nxSandboxId, setNxSandboxId] = useState<string | null>(null);
266
+ const [nxSandboxUrl, setNxSandboxUrl] = useState<string | null>(null);
267
+ const [nxStarting, setNxStarting] = useState(false);
268
+ const [nxError, setNxError] = useState<string | null>(null);
269
+ const nxIframeRef = useRef<HTMLIFrameElement>(null);
270
+ // Simulated URL bar state for Next.js mode
271
+ const [reactPreviewPath, setReactPreviewPath] = useState("/");
272
+ const [reactNavInput, setReactNavInput] = useState("/");
273
+ const [reactNavHistory, setReactNavHistory] = useState<string[]>(["/"]);
274
+ const [reactNavIndex, setReactNavIndex] = useState(0);
275
+
276
+ // ── Sandbox output tab ("output" | "chat") ──────────────────
277
+ const [sbxBottomTab, setSbxBottomTab] = useState<"output" | "chat">("output");
278
+
224
279
  // ── Sandbox panel sizes ─────────────────────────────────────────
225
280
  // sbxSplit: server pane width as % of the editor row (0–100)
226
281
  const [sbxSplit, setSbxSplit] = useState(50);
@@ -246,23 +301,92 @@ export default function CodeRunnerModal() {
246
301
  const [activeSandboxId, setActiveSandboxId] = useState<string | null>(null);
247
302
  const sbxNameInputRef = useRef<HTMLInputElement>(null);
248
303
 
249
- // Save server+client together as one JSON blob with origin 'sandbox'
304
+ // ── Sandbox chat state (declared after activeSandboxId) ──────────────
305
+ type SbxChatMessage = {
306
+ id: string;
307
+ role: "user" | "assistant";
308
+ content: string;
309
+ };
310
+ const sbxChatKey = `sbx-chat:${activeSandboxId ?? `q:${currentQuestion?.id ?? "_"}`}`;
311
+ const sbxChatKeyRef = useRef(sbxChatKey);
312
+ const [sbxChatMessages, setSbxChatMessages] = useState<SbxChatMessage[]>(
313
+ () => {
314
+ try {
315
+ const s = localStorage.getItem(sbxChatKey);
316
+ return s ? (JSON.parse(s) as SbxChatMessage[]) : [];
317
+ } catch {
318
+ return [];
319
+ }
320
+ },
321
+ );
322
+ const [sbxChatInput, setSbxChatInput] = useState("");
323
+ const [sbxChatLoading, setSbxChatLoading] = useState(false);
324
+ const sbxChatScrollRef = useRef<HTMLDivElement>(null);
325
+ const sbxChatInputRef = useRef<HTMLTextAreaElement>(null);
326
+ const sbxChatAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
327
+ const [sbxChatCopiedId, setSbxChatCopiedId] = useState<string | null>(null);
328
+
329
+ // Keep key ref fresh
330
+ useEffect(() => {
331
+ sbxChatKeyRef.current = sbxChatKey;
332
+ }, [sbxChatKey]);
333
+ // Reload chat when artifact changes
334
+ useEffect(() => {
335
+ try {
336
+ const s = localStorage.getItem(sbxChatKey);
337
+ setSbxChatMessages(s ? (JSON.parse(s) as SbxChatMessage[]) : []);
338
+ } catch {
339
+ setSbxChatMessages([]);
340
+ }
341
+ }, [sbxChatKey]);
342
+ // Persist chat
343
+ useEffect(() => {
344
+ if (sbxChatMessages.length === 0) {
345
+ localStorage.removeItem(sbxChatKeyRef.current);
346
+ return;
347
+ }
348
+ localStorage.setItem(
349
+ sbxChatKeyRef.current,
350
+ JSON.stringify(sbxChatMessages),
351
+ );
352
+ }, [sbxChatMessages]);
353
+ // Auto-scroll chat
354
+ useEffect(() => {
355
+ if (sbxBottomTab === "chat" && sbxChatScrollRef.current)
356
+ sbxChatScrollRef.current.scrollTop =
357
+ sbxChatScrollRef.current.scrollHeight;
358
+ }, [sbxChatMessages, sbxChatLoading, sbxBottomTab]);
359
+
360
+ // Save server+client together as one JSON blob
250
361
  const saveSandboxSnippet = async (label: string) => {
251
362
  if (!currentQuestion) return;
252
363
  setSbxSaving(true);
253
364
  try {
254
- const payload = JSON.stringify({
255
- serverCode,
256
- serverLang,
257
- clientCode,
258
- clientLang,
259
- });
365
+ const origin =
366
+ clientType === "react"
367
+ ? "react"
368
+ : clientType === "nextjs"
369
+ ? "nextjs"
370
+ : "sandbox";
371
+ const payload = JSON.stringify(
372
+ clientType === "script"
373
+ ? { serverCode, serverLang, clientCode, clientLang }
374
+ : {
375
+ serverCode,
376
+ serverLang,
377
+ clientCode: "",
378
+ clientLang: "javascript",
379
+ clientType,
380
+ reactFiles,
381
+ reactActiveFile,
382
+ },
383
+ );
260
384
  const cf = await saveCodeSnippetToQuestion(
261
385
  currentQuestion.id,
262
386
  payload,
263
- "sandbox",
387
+ origin,
264
388
  label || "My Sandbox",
265
- "sandbox",
389
+ origin,
266
390
  );
267
391
  setActiveSandboxId(cf.id);
268
392
  setSbxSaved(true);
@@ -277,12 +401,19 @@ export default function CodeRunnerModal() {
277
401
  if (!currentQuestion || !activeSandboxId) return;
278
402
  setSbxSaving(true);
279
403
  try {
280
- const payload = JSON.stringify({
281
- serverCode,
282
- serverLang,
283
- clientCode,
284
- clientLang,
285
- });
404
+ const payload = JSON.stringify(
405
+ clientType === "script"
406
+ ? { serverCode, serverLang, clientCode, clientLang }
407
+ : {
408
+ serverCode,
409
+ serverLang,
410
+ clientCode: "",
411
+ clientLang: "javascript",
412
+ clientType,
413
+ reactFiles,
414
+ reactActiveFile,
415
+ },
416
+ );
286
417
  await overwriteContextFileContent(
287
418
  currentQuestion.id,
288
419
  activeSandboxId,
@@ -295,6 +426,23 @@ export default function CodeRunnerModal() {
295
426
  }
296
427
  };
297
428
 
429
+ // Overwrite the existing script snippet in-place
430
+ const overwriteScriptSnippet = async () => {
431
+ if (!currentQuestion || !activeScriptId) return;
432
+ setSaving(true);
433
+ try {
434
+ await overwriteContextFileContent(
435
+ currentQuestion.id,
436
+ activeScriptId,
437
+ code,
438
+ );
439
+ setSaved(true);
440
+ setTimeout(() => setSaved(false), 2000);
441
+ } finally {
442
+ setSaving(false);
443
+ }
444
+ };
445
+
298
446
  const outputEndRef = useRef<HTMLDivElement>(null);
299
447
  const nameInputRef = useRef<HTMLInputElement>(null);
300
448
  // Tracks how many server log lines have already been flushed to sandboxOutput
@@ -324,6 +472,30 @@ export default function CodeRunnerModal() {
324
472
  setClientLang((runnerInitialSandbox.clientLang as Lang) ?? "javascript");
325
473
  setSandboxOutput([]);
326
474
  setActiveSandboxId(runnerInitialSandbox.fileId ?? null);
475
+ // Restore client type and React/Next.js files
476
+ const ct =
477
+ (runnerInitialSandbox.clientType as "script" | "react" | "nextjs") ??
478
+ "script";
479
+ setClientType(ct);
480
+ if (ct === "react" || ct === "nextjs") {
481
+ if (
482
+ runnerInitialSandbox.reactFiles &&
483
+ Object.keys(runnerInitialSandbox.reactFiles).length > 0
484
+ ) {
485
+ setReactFiles(runnerInitialSandbox.reactFiles);
486
+ setReactActiveFile(
487
+ runnerInitialSandbox.reactActiveFile ??
488
+ Object.keys(runnerInitialSandbox.reactFiles)[0] ??
489
+ "",
490
+ );
491
+ } else {
492
+ const defs = defaultForType(ct);
493
+ setReactFiles(defs.files);
494
+ setReactActiveFile(defs.activeFile);
495
+ }
496
+ setReactPreviewSrc(null);
497
+ setReactClientTab("edit");
498
+ }
327
499
  }, [runnerInitialSandbox]);
328
500
 
329
501
  // Auto-focus is handled inside SyntaxEditor via autoFocus prop.
@@ -595,7 +767,351 @@ export default function CodeRunnerModal() {
595
767
  return () => clearInterval(interval);
596
768
  }, [sandboxId]);
597
769
 
598
- // ── Sandbox handlers ─────────────────────────────────────
770
+ // ── React/Next.js helpers ─────────────────────────────────────
771
+
772
+ /** Seed sensible default content for a freshly created file. */
773
+ const newFileContent = (name: string): string => {
774
+ const base = name.split("/").pop() ?? name;
775
+ // Next.js special files
776
+ if (base === "page.tsx" || base === "page.ts") {
777
+ const routeSegments = name
778
+ .replace(/^app\//, "")
779
+ .replace(/\/page\.tsx?$/, "")
780
+ .split("/")
781
+ .filter(Boolean);
782
+ const routeName =
783
+ routeSegments.length === 0
784
+ ? "Home"
785
+ : routeSegments
786
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
787
+ .join("");
788
+ const urlPath =
789
+ routeSegments.length === 0 ? "/" : "/" + routeSegments.join("/");
790
+ return [
791
+ `// ${urlPath} → ${name}`,
792
+ `// Server Component by default — no "use client" needed unless you use hooks`,
793
+ ``,
794
+ `export default function ${routeName}Page() {`,
795
+ ` return (`,
796
+ ` <div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>`,
797
+ ` <h1 style={{ fontSize: "1.5rem", fontWeight: "bold" }}>${routeName}</h1>`,
798
+ ` <p style={{ color: "#64748b", marginTop: "0.5rem" }}>`,
799
+ ` You are on <code>${urlPath}</code>`,
800
+ ` </p>`,
801
+ ` <button`,
802
+ ` onClick={() => (window as any).__nxNavigate("/")}`,
803
+ ` style={{ marginTop: "1rem", padding: "0.5rem 1rem", cursor: "pointer",`,
804
+ ` borderRadius: "0.375rem", border: "1px solid #cbd5e1", background: "#f8fafc" }}`,
805
+ ` >`,
806
+ ` ← Back to Home`,
807
+ ` </button>`,
808
+ ` </div>`,
809
+ ` );`,
810
+ `}`,
811
+ ``,
812
+ ].join("\n");
813
+ }
814
+ if (base === "layout.tsx" || base === "layout.ts") {
815
+ return [
816
+ `// Root Layout — wraps ALL pages, persists across navigations`,
817
+ ``,
818
+ `export default function Layout({ children }: { children: React.ReactNode }) {`,
819
+ ` return (`,
820
+ ` <html lang="en">`,
821
+ ` <body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>`,
822
+ ` {children}`,
823
+ ` </body>`,
824
+ ` </html>`,
825
+ ` );`,
826
+ `}`,
827
+ ``,
828
+ ].join("\n");
829
+ }
830
+ if (base === "loading.tsx" || base === "loading.ts") {
831
+ return [
832
+ `// Shown automatically while the page is loading (Suspense boundary)`,
833
+ ``,
834
+ `export default function Loading() {`,
835
+ ` return <p style={{ padding: "2rem", color: "#64748b" }}>Loading…</p>;`,
836
+ `}`,
837
+ ``,
838
+ ].join("\n");
839
+ }
840
+ if (base === "error.tsx" || base === "error.ts") {
841
+ return [
842
+ `"use client"; // error boundaries must be Client Components`,
843
+ ``,
844
+ `export default function Error({ error, reset }: { error: Error; reset: () => void }) {`,
845
+ ` return (`,
846
+ ` <div style={{ padding: "2rem" }}>`,
847
+ ` <h2>Something went wrong</h2>`,
848
+ ` <pre style={{ color: "#dc2626", fontSize: "0.8rem" }}>{error.message}</pre>`,
849
+ ` <button onClick={reset}>Try again</button>`,
850
+ ` </div>`,
851
+ ` );`,
852
+ `}`,
853
+ ``,
854
+ ].join("\n");
855
+ }
856
+ return `// ${name}\n`;
857
+ };
858
+
859
+ const getReactEntry = (
860
+ files: Record<string, string>,
861
+ type: "react" | "nextjs",
862
+ ): string => {
863
+ if (type === "nextjs")
864
+ return files["app/page.tsx"]
865
+ ? "app/page.tsx"
866
+ : (Object.keys(files)[0] ?? "");
867
+ return files["App.tsx"] ? "App.tsx" : (Object.keys(files)[0] ?? "");
868
+ };
869
+
870
+ const refreshPreview = useCallback(
871
+ (overridePath?: string) => {
872
+ const type = clientType as "react" | "nextjs";
873
+ let entry: string;
874
+ if (type === "nextjs") {
875
+ const path = overridePath ?? reactPreviewPath;
876
+ const resolved = resolveNextjsEntry(reactFiles, path);
877
+ if (!resolved) {
878
+ // Show a proper 404 — don't fall back to the home page
879
+ const notFoundHTML = generatePreviewHTML(
880
+ {
881
+ "__404__.tsx": [
882
+ `export default function NotFound() {`,
883
+ ` const path = ${JSON.stringify(path)};`,
884
+ ` return (`,
885
+ ` <div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>`,
886
+ ` <h1 style={{ fontSize: "1.5rem", fontWeight: "bold", color: "#dc2626" }}>404 — Page Not Found</h1>`,
887
+ ` <p style={{ color: "#64748b", marginTop: "0.5rem" }}>`,
888
+ ` No file matches <code style={{ background: "#f1f5f9", padding: "0.1em 0.4em", borderRadius: "0.25rem" }}>{path}</code>`,
889
+ ` </p>`,
890
+ ` <p style={{ color: "#94a3b8", fontSize: "0.875rem", marginTop: "1rem" }}>`,
891
+ ` Create <code style={{ background: "#f1f5f9", padding: "0.1em 0.4em", borderRadius: "0.25rem" }}>app${path === "/" ? "" : path}/page.tsx</code> to make this route work.`,
892
+ ` </p>`,
893
+ ` <button onClick={() => (window as any).__nxNavigate("/")}`,
894
+ ` style={{ marginTop: "1.5rem", padding: "0.5rem 1rem", cursor: "pointer",`,
895
+ ` borderRadius: "0.375rem", border: "1px solid #cbd5e1", background: "#f8fafc" }}>`,
896
+ ` ← Back to Home`,
897
+ ` </button>`,
898
+ ` </div>`,
899
+ ` );`,
900
+ `}`,
901
+ ].join("\n"),
902
+ },
903
+ "__404__.tsx",
904
+ undefined,
905
+ true,
906
+ );
907
+ setReactPreviewSrc(notFoundHTML);
908
+ setReactClientTab("preview");
909
+ return;
910
+ }
911
+ entry = resolved;
912
+ } else {
913
+ entry = getReactEntry(reactFiles, type);
914
+ }
915
+ if (!entry) return;
916
+ const html = generatePreviewHTML(
917
+ reactFiles,
918
+ entry,
919
+ sandboxUrl ?? undefined,
920
+ type === "nextjs",
921
+ );
922
+ setReactPreviewSrc(html);
923
+ setReactClientTab("preview");
924
+ },
925
+ [clientType, reactFiles, sandboxUrl, reactPreviewPath],
926
+ );
927
+
928
+ // Auto-refresh preview when files change while the preview tab is visible
929
+ const reactFilesRef = useRef(reactFiles);
930
+ useEffect(() => {
931
+ if (reactFiles === reactFilesRef.current) return;
932
+ reactFilesRef.current = reactFiles;
933
+ if (reactClientTab === "preview" && reactPreviewSrc) {
934
+ refreshPreview();
935
+ }
936
+ }, [reactFiles, reactClientTab, reactPreviewSrc, refreshPreview]);
937
+
938
+ /** Navigate to a new path (updates URL bar + history + re-renders preview). */
939
+ const navigatePreview = useCallback(
940
+ (to: string) => {
941
+ const path = to.startsWith("/") ? to : "/" + to;
942
+ setReactPreviewPath(path);
943
+ setReactNavInput(path);
944
+ setReactNavHistory((prev) => {
945
+ const trimmed = prev.slice(0, reactNavIndex + 1);
946
+ return [...trimmed, path];
947
+ });
948
+ setReactNavIndex((i) => i + 1);
949
+ refreshPreview(path);
950
+ },
951
+ [reactNavIndex, refreshPreview],
952
+ );
953
+
954
+ // Listen for rlab-nav messages from the preview iframe
955
+ useEffect(() => {
956
+ const handler = (e: MessageEvent) => {
957
+ if (e.data?.type === "rlab-nav" && typeof e.data.to === "string") {
958
+ navigatePreview(e.data.to);
959
+ }
960
+ };
961
+ window.addEventListener("message", handler);
962
+ return () => window.removeEventListener("message", handler);
963
+ }, [navigatePreview]);
964
+
965
+ /** Start a real Next.js dev-server for the lab and point the iframe at it. */
966
+ const startNextjsServer = useCallback(async () => {
967
+ if (nxStarting) return;
968
+ setNxStarting(true);
969
+ setNxError(null);
970
+ try {
971
+ const info = await startNextjsSandbox(reactFiles);
972
+ setNxSandboxId(info.id);
973
+ setNxSandboxUrl(info.url);
974
+ setReactClientTab("preview");
975
+ } catch (err: any) {
976
+ setNxError(err?.message ?? String(err));
977
+ } finally {
978
+ setNxStarting(false);
979
+ }
980
+ }, [nxStarting, reactFiles]);
981
+
982
+ /** Push updated files to the running Next.js server (HMR picks them up). */
983
+ const pushNextjsFiles = useCallback(
984
+ async (files: Record<string, string>) => {
985
+ if (!nxSandboxId) return;
986
+ try {
987
+ await updateNextjsFiles(nxSandboxId, files);
988
+ } catch {
989
+ // non-fatal; HMR may already have picked up the change
990
+ }
991
+ },
992
+ [nxSandboxId],
993
+ );
994
+
995
+ // Auto-push file changes to the running Next.js server
996
+ const nxFilesRef = useRef(reactFiles);
997
+ useEffect(() => {
998
+ if (!nxSandboxId || reactFiles === nxFilesRef.current) return;
999
+ nxFilesRef.current = reactFiles;
1000
+ void pushNextjsFiles(reactFiles);
1001
+ }, [reactFiles, nxSandboxId, pushNextjsFiles]);
1002
+
1003
+ // Clean up Next.js server when the modal is closed or mode changes away from nextjs
1004
+ const prevClientTypeRef = useRef(clientType);
1005
+ useEffect(() => {
1006
+ const prev = prevClientTypeRef.current;
1007
+ prevClientTypeRef.current = clientType;
1008
+ if (prev === "nextjs" && clientType !== "nextjs" && nxSandboxId) {
1009
+ void stopNextjsSandbox(nxSandboxId);
1010
+ setNxSandboxId(null);
1011
+ setNxSandboxUrl(null);
1012
+ }
1013
+ }, [clientType, nxSandboxId]);
1014
+
1015
+ // Clean up on unmount
1016
+ useEffect(() => {
1017
+ return () => {
1018
+ if (nxSandboxId) void stopNextjsSandbox(nxSandboxId);
1019
+ };
1020
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1021
+ }, [nxSandboxId]);
1022
+
1023
+ const handleClientTypeChange = useCallback(
1024
+ (ct: "script" | "react" | "nextjs") => {
1025
+ if (ct === clientType) return;
1026
+ setClientType(ct);
1027
+ if (ct === "react" || ct === "nextjs") {
1028
+ const defs = defaultForType(ct);
1029
+ setReactFiles(defs.files);
1030
+ setReactActiveFile(defs.activeFile);
1031
+ setReactPreviewSrc(null);
1032
+ setReactClientTab("edit");
1033
+ if (ct === "nextjs") {
1034
+ setReactPreviewPath("/");
1035
+ setReactNavInput("/");
1036
+ setReactNavHistory(["/"]);
1037
+ setReactNavIndex(0);
1038
+ }
1039
+ }
1040
+ },
1041
+ [clientType],
1042
+ );
1043
+
1044
+ // ── Sandbox chat handler ──────────────────────────────────
1045
+
1046
+ const handleSbxChatSend = useCallback(async () => {
1047
+ const text = sbxChatInput.trim();
1048
+ if (!text || sbxChatLoading) return;
1049
+ setSbxChatInput("");
1050
+ const userMsg: SbxChatMessage = {
1051
+ id: crypto.randomUUID(),
1052
+ role: "user",
1053
+ content: text,
1054
+ };
1055
+ setSbxChatMessages((prev) => [...prev, userMsg]);
1056
+ setSbxChatLoading(true);
1057
+ const abort = { aborted: false };
1058
+ sbxChatAbortRef.current = abort;
1059
+ const aId = crypto.randomUUID();
1060
+ setSbxChatMessages((prev) => [
1061
+ ...prev,
1062
+ { id: aId, role: "assistant", content: "" },
1063
+ ]);
1064
+ const isReactMode = clientType === "react" || clientType === "nextjs";
1065
+ const workspaceFiles = isReactMode
1066
+ ? reactFiles
1067
+ : { "client.js": clientCode, "server.ts": serverCode };
1068
+ const labType: "react" | "nextjs" =
1069
+ clientType === "nextjs" ? "nextjs" : "react";
1070
+ try {
1071
+ const history = [...sbxChatMessages, userMsg].map((m) => ({
1072
+ role: m.role,
1073
+ content: m.content,
1074
+ }));
1075
+ const { streamFrontendLabAsk } = await import("../api");
1076
+ await streamFrontendLabAsk(
1077
+ {
1078
+ messages: history,
1079
+ workspace: workspaceFiles,
1080
+ labType,
1081
+ questionId: currentQuestion?.id,
1082
+ },
1083
+ (delta) => {
1084
+ if (abort.aborted) return;
1085
+ setSbxChatMessages((prev) =>
1086
+ prev.map((m) =>
1087
+ m.id === aId ? { ...m, content: m.content + delta } : m,
1088
+ ),
1089
+ );
1090
+ },
1091
+ );
1092
+ } catch (err: unknown) {
1093
+ if (!abort.aborted)
1094
+ setSbxChatMessages((prev) =>
1095
+ prev.map((m) =>
1096
+ m.id === aId
1097
+ ? { ...m, content: (err as Error)?.message ?? "Request failed" }
1098
+ : m,
1099
+ ),
1100
+ );
1101
+ } finally {
1102
+ if (!abort.aborted) setSbxChatLoading(false);
1103
+ }
1104
+ }, [
1105
+ sbxChatInput,
1106
+ sbxChatLoading,
1107
+ sbxChatMessages,
1108
+ clientType,
1109
+ reactFiles,
1110
+ clientCode,
1111
+ serverCode,
1112
+ ]);
1113
+
1114
+ // ── Sandbox handlers ─────────────────────────────────────────────
599
1115
 
600
1116
  const startServer = async () => {
601
1117
  if (serverStarting) return;
@@ -856,27 +1372,40 @@ export default function CodeRunnerModal() {
856
1372
  type="button"
857
1373
  onMouseDown={(e) => e.stopPropagation()}
858
1374
  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);
1375
+ if (activeScriptId) {
1376
+ void overwriteScriptSnippet();
1377
+ } else {
1378
+ if (!code.trim()) return;
1379
+ setSnippetName("Runner snippet");
1380
+ setNaming(true);
1381
+ setTimeout(() => {
1382
+ nameInputRef.current?.focus();
1383
+ nameInputRef.current?.select();
1384
+ }, 30);
1385
+ }
866
1386
  }}
867
- disabled={!code.trim()}
1387
+ disabled={saving || (!activeScriptId && !code.trim())}
868
1388
  className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-colors shrink-0 ${
869
1389
  saved
870
1390
  ? "bg-cyan-600/30 text-cyan-300"
871
1391
  : "bg-slate-700/60 hover:bg-slate-600/60 text-slate-400 hover:text-slate-200"
872
1392
  } disabled:opacity-40`}
873
- title="Save to question context"
1393
+ title={
1394
+ activeScriptId
1395
+ ? "Overwrite saved script"
1396
+ : "Save to question context"
1397
+ }
874
1398
  >
875
1399
  {saved ? (
876
1400
  <>
877
1401
  <Check className="w-3 h-3" />
878
1402
  Saved
879
1403
  </>
1404
+ ) : saving ? (
1405
+ <>
1406
+ <Loader2 className="w-3 h-3 animate-spin" />
1407
+ Saving…
1408
+ </>
880
1409
  ) : (
881
1410
  <>
882
1411
  <Save className="w-3 h-3" />
@@ -886,6 +1415,28 @@ export default function CodeRunnerModal() {
886
1415
  </button>
887
1416
  )}
888
1417
 
1418
+ {/* Save As — only shown when a script is already saved */}
1419
+ {currentQuestion && !naming && activeScriptId && (
1420
+ <button
1421
+ type="button"
1422
+ onMouseDown={(e) => e.stopPropagation()}
1423
+ onClick={() => {
1424
+ if (!code.trim()) return;
1425
+ setSnippetName("Runner snippet");
1426
+ setNaming(true);
1427
+ setTimeout(() => {
1428
+ nameInputRef.current?.focus();
1429
+ nameInputRef.current?.select();
1430
+ }, 30);
1431
+ }}
1432
+ disabled={!code.trim() || saving}
1433
+ 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"
1434
+ title="Save as a new script"
1435
+ >
1436
+ <Save className="w-3 h-3" /> Save As
1437
+ </button>
1438
+ )}
1439
+
889
1440
  {/* Inline name input */}
890
1441
  {currentQuestion && naming && (
891
1442
  <div
@@ -903,13 +1454,14 @@ export default function CodeRunnerModal() {
903
1454
  setSaving(true);
904
1455
  setNaming(false);
905
1456
  try {
906
- await saveCodeSnippetToQuestion(
1457
+ const cf = await saveCodeSnippetToQuestion(
907
1458
  currentQuestion.id,
908
1459
  code,
909
1460
  lang,
910
1461
  label,
911
1462
  "user",
912
1463
  );
1464
+ setActiveScriptId(cf.id);
913
1465
  setSaved(true);
914
1466
  setTimeout(() => setSaved(false), 2000);
915
1467
  } finally {
@@ -929,13 +1481,14 @@ export default function CodeRunnerModal() {
929
1481
  setSaving(true);
930
1482
  setNaming(false);
931
1483
  try {
932
- await saveCodeSnippetToQuestion(
1484
+ const cf = await saveCodeSnippetToQuestion(
933
1485
  currentQuestion.id,
934
1486
  code,
935
1487
  lang,
936
1488
  label,
937
1489
  "user",
938
1490
  );
1491
+ setActiveScriptId(cf.id);
939
1492
  setSaved(true);
940
1493
  setTimeout(() => setSaved(false), 2000);
941
1494
  } finally {
@@ -1383,11 +1936,34 @@ export default function CodeRunnerModal() {
1383
1936
  className="flex flex-col min-w-0 overflow-hidden"
1384
1937
  style={{ flex: "1 1 0" }}
1385
1938
  >
1939
+ {/* Client panel header */}
1386
1940
  <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
1941
  <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">
1942
+ <span className="text-[10px] uppercase tracking-wider text-slate-500 font-medium">
1389
1943
  Client
1390
1944
  </span>
1945
+ {/* Client type selector: JS / React / Next */}
1946
+ <div className="flex items-center rounded overflow-hidden border border-slate-700 text-[9px] ml-1 shrink-0">
1947
+ {(["script", "react", "nextjs"] as const).map((ct) => (
1948
+ <button
1949
+ key={ct}
1950
+ type="button"
1951
+ onClick={() => handleClientTypeChange(ct)}
1952
+ className={`px-1.5 py-0.5 transition-colors ${
1953
+ clientType === ct
1954
+ ? "bg-slate-600 text-slate-200"
1955
+ : "text-slate-500 hover:text-slate-400"
1956
+ }`}
1957
+ >
1958
+ {ct === "script"
1959
+ ? "JS"
1960
+ : ct === "react"
1961
+ ? "React"
1962
+ : "Next"}
1963
+ </button>
1964
+ ))}
1965
+ </div>
1966
+ <div className="flex-1" />
1391
1967
  <button
1392
1968
  type="button"
1393
1969
  onClick={() => {
@@ -1399,63 +1975,732 @@ export default function CodeRunnerModal() {
1399
1975
  >
1400
1976
  <ChevronRight className="w-3 h-3" />
1401
1977
  </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>
1978
+ {/* Script mode: URL + lang toggle + Run */}
1979
+ {clientType === "script" && (
1980
+ <>
1981
+ {sandboxUrl && (
1982
+ <span
1983
+ className="text-[9px] font-mono text-slate-600 truncate max-w-[90px]"
1984
+ title={sandboxUrl}
1985
+ >
1986
+ {sandboxUrl}
1987
+ </span>
1988
+ )}
1989
+ <div className="flex items-center gap-0.5">
1990
+ {LANG_OPTIONS.map((l) => (
1991
+ <button
1992
+ key={l}
1993
+ type="button"
1994
+ onClick={() => setClientLang(l)}
1995
+ className={`px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wider font-mono transition-colors ${
1996
+ clientLang === l
1997
+ ? "bg-violet-600/30 text-violet-300"
1998
+ : "text-slate-600 hover:text-slate-300"
1999
+ }`}
2000
+ >
2001
+ {l === "typescript" ? "TS" : "JS"}
2002
+ </button>
2003
+ ))}
2004
+ </div>
2005
+ <button
2006
+ type="button"
2007
+ onClick={() => void runClient()}
2008
+ disabled={clientRunning || !serverRunning}
2009
+ 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"
2010
+ title={
2011
+ serverRunning
2012
+ ? "Run client (Ctrl+Enter in editor)"
2013
+ : "Start the server first"
2014
+ }
2015
+ >
2016
+ {clientRunning ? (
2017
+ <Loader2 className="w-3 h-3 animate-spin" />
2018
+ ) : (
2019
+ <Play className="w-3 h-3" />
2020
+ )}
2021
+ Run
2022
+ </button>
2023
+ </>
1409
2024
  )}
1410
- <div className="flex items-center gap-0.5">
1411
- {LANG_OPTIONS.map((l) => (
2025
+ {/* React/Next mode: optional URL + Preview button + edit/preview toggle for Next */}
2026
+ {(clientType === "react" || clientType === "nextjs") && (
2027
+ <>
2028
+ {sandboxUrl && (
2029
+ <span
2030
+ className="text-[9px] font-mono text-slate-600 truncate max-w-[80px]"
2031
+ title={sandboxUrl}
2032
+ >
2033
+ {sandboxUrl}
2034
+ </span>
2035
+ )}
2036
+ {/* React mode: simple preview button */}
2037
+ {clientType === "react" && (
2038
+ <button
2039
+ type="button"
2040
+ onClick={refreshPreview}
2041
+ 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"
2042
+ title="Render preview"
2043
+ >
2044
+ <Eye className="w-3 h-3" />
2045
+ Preview
2046
+ </button>
2047
+ )}
2048
+ {/* Next.js mode: start real server OR edit/preview toggle */}
2049
+ {clientType === "nextjs" && (
2050
+ <>
2051
+ {!nxSandboxUrl ? (
2052
+ <button
2053
+ type="button"
2054
+ onClick={() => void startNextjsServer()}
2055
+ disabled={nxStarting}
2056
+ 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"
2057
+ title="Start real Next.js dev server"
2058
+ >
2059
+ {nxStarting ? (
2060
+ <Loader2 className="w-3 h-3 animate-spin" />
2061
+ ) : (
2062
+ <Play className="w-3 h-3" />
2063
+ )}
2064
+ {nxStarting ? "Starting…" : "Run Next.js"}
2065
+ </button>
2066
+ ) : (
2067
+ <div className="flex items-center rounded overflow-hidden border border-slate-700/50 text-[9px] shrink-0">
2068
+ <button
2069
+ type="button"
2070
+ onClick={() => setReactClientTab("edit")}
2071
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2072
+ reactClientTab === "edit"
2073
+ ? "bg-slate-700 text-slate-200"
2074
+ : "text-slate-500 hover:text-slate-400"
2075
+ }`}
2076
+ title="Edit code"
2077
+ >
2078
+ <Code2 className="w-2.5 h-2.5" />
2079
+ </button>
2080
+ <button
2081
+ type="button"
2082
+ onClick={() => setReactClientTab("preview")}
2083
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2084
+ reactClientTab === "preview"
2085
+ ? "bg-slate-700 text-slate-200"
2086
+ : "text-slate-500 hover:text-slate-400"
2087
+ }`}
2088
+ title="Live preview"
2089
+ >
2090
+ <Eye className="w-2.5 h-2.5" />
2091
+ </button>
2092
+ </div>
2093
+ )}
2094
+ </>
2095
+ )}
2096
+ </>
2097
+ )}
2098
+ </div>
2099
+
2100
+ {/* File tabs row (React only — Next.js uses the tree sidebar) */}
2101
+ {clientType === "react" && (
2102
+ <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">
2103
+ {Object.keys(reactFiles).map((fname) => (
1412
2104
  <button
1413
- key={l}
2105
+ key={fname}
1414
2106
  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"
2107
+ onClick={() => {
2108
+ setReactActiveFile(fname);
2109
+ setReactClientTab("edit");
2110
+ }}
2111
+ className={`flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-mono whitespace-nowrap transition-colors ${
2112
+ fname === reactActiveFile && reactClientTab === "edit"
2113
+ ? "bg-slate-900 text-slate-200 border border-slate-600"
2114
+ : "text-slate-500 hover:text-slate-300 hover:bg-slate-800/50"
1420
2115
  }`}
1421
2116
  >
1422
- {l === "typescript" ? "TS" : "JS"}
2117
+ {fname.includes("/") ? fname.split("/").pop() : fname}
2118
+ <span
2119
+ role="button"
2120
+ onClick={(e) => {
2121
+ e.stopPropagation();
2122
+ if (Object.keys(reactFiles).length <= 1) return;
2123
+ const remaining = Object.keys(reactFiles).filter(
2124
+ (f) => f !== fname,
2125
+ );
2126
+ setReactFiles((prev) => {
2127
+ const next = { ...prev };
2128
+ delete next[fname];
2129
+ return next;
2130
+ });
2131
+ if (reactActiveFile === fname)
2132
+ setReactActiveFile(remaining[0] ?? "");
2133
+ }}
2134
+ className="w-3 h-3 flex items-center justify-center text-slate-600 hover:text-red-400 rounded transition-colors"
2135
+ title="Delete file"
2136
+ >
2137
+ <X className="w-2.5 h-2.5" />
2138
+ </span>
1423
2139
  </button>
1424
2140
  ))}
2141
+ {/* Add new file */}
2142
+ {reactAddingFile ? (
2143
+ <input
2144
+ autoFocus
2145
+ value={reactNewFileName}
2146
+ onChange={(e) => setReactNewFileName(e.target.value)}
2147
+ onBlur={() => {
2148
+ setReactAddingFile(false);
2149
+ setReactNewFileName("");
2150
+ }}
2151
+ onKeyDown={(e) => {
2152
+ if (e.key === "Enter") {
2153
+ e.preventDefault();
2154
+ const name = reactNewFileName.trim();
2155
+ if (name && !reactFiles[name]) {
2156
+ setReactFiles((prev) => ({
2157
+ ...prev,
2158
+ [name]: newFileContent(name),
2159
+ }));
2160
+ setReactActiveFile(name);
2161
+ setReactClientTab("edit");
2162
+ }
2163
+ setReactAddingFile(false);
2164
+ setReactNewFileName("");
2165
+ } else if (e.key === "Escape") {
2166
+ setReactAddingFile(false);
2167
+ setReactNewFileName("");
2168
+ }
2169
+ }}
2170
+ placeholder="filename.tsx"
2171
+ 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"
2172
+ />
2173
+ ) : (
2174
+ <button
2175
+ type="button"
2176
+ onClick={() => setReactAddingFile(true)}
2177
+ className="p-0.5 rounded text-slate-600 hover:text-cyan-400 transition-colors shrink-0"
2178
+ title="New file"
2179
+ >
2180
+ <FilePlus className="w-3 h-3" />
2181
+ </button>
2182
+ )}
2183
+ {/* Edit / Preview tab toggle */}
2184
+ <div className="ml-auto flex items-center rounded overflow-hidden border border-slate-700/50 text-[9px] shrink-0">
2185
+ <button
2186
+ type="button"
2187
+ onClick={() => setReactClientTab("edit")}
2188
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2189
+ reactClientTab === "edit"
2190
+ ? "bg-slate-700 text-slate-200"
2191
+ : "text-slate-500 hover:text-slate-400"
2192
+ }`}
2193
+ title="Edit code"
2194
+ >
2195
+ <Code2 className="w-2.5 h-2.5" />
2196
+ </button>
2197
+ <button
2198
+ type="button"
2199
+ onClick={() => {
2200
+ if (!reactPreviewSrc) refreshPreview();
2201
+ else setReactClientTab("preview");
2202
+ }}
2203
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2204
+ reactClientTab === "preview"
2205
+ ? "bg-slate-700 text-slate-200"
2206
+ : "text-slate-500 hover:text-slate-400"
2207
+ }`}
2208
+ title="Live preview"
2209
+ >
2210
+ <Eye className="w-2.5 h-2.5" />
2211
+ </button>
2212
+ </div>
1425
2213
  </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
- }
2214
+ )}
2215
+
2216
+ {/* Client body */}
2217
+ <div
2218
+ className={`flex-1 min-h-0 ${clientType === "nextjs" ? "flex flex-row" : "relative"}`}
2219
+ >
2220
+ {/* ── Next.js VS Code-style file tree sidebar ── */}
2221
+ {clientType === "nextjs" && (
2222
+ <div className="w-36 shrink-0 flex flex-col border-r border-slate-700 bg-slate-900/60 overflow-y-auto">
2223
+ {/* Sidebar header */}
2224
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-700/60">
2225
+ <span className="text-[9px] uppercase tracking-widest text-slate-500 font-semibold select-none">
2226
+ Explorer
2227
+ </span>
2228
+ {/* Add file button */}
2229
+ {reactAddingFile ? (
2230
+ <input
2231
+ autoFocus
2232
+ value={reactNewFileName}
2233
+ onChange={(e) =>
2234
+ setReactNewFileName(e.target.value)
2235
+ }
2236
+ onBlur={() => {
2237
+ setReactAddingFile(false);
2238
+ setReactNewFileName("");
2239
+ }}
2240
+ onKeyDown={(e) => {
2241
+ if (e.key === "Enter") {
2242
+ e.preventDefault();
2243
+ const name = reactNewFileName.trim();
2244
+ if (name && !reactFiles[name]) {
2245
+ setReactFiles((prev) => ({
2246
+ ...prev,
2247
+ [name]: newFileContent(name),
2248
+ }));
2249
+ setReactActiveFile(name);
2250
+ setReactClientTab("edit");
2251
+ }
2252
+ setReactAddingFile(false);
2253
+ setReactNewFileName("");
2254
+ } else if (e.key === "Escape") {
2255
+ setReactAddingFile(false);
2256
+ setReactNewFileName("");
2257
+ }
2258
+ }}
2259
+ placeholder="app/new.tsx"
2260
+ 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"
2261
+ />
2262
+ ) : (
2263
+ <button
2264
+ type="button"
2265
+ onClick={() => setReactAddingFile(true)}
2266
+ className="p-0.5 rounded text-slate-600 hover:text-cyan-400 transition-colors"
2267
+ title="New file (use paths like app/dashboard/page.tsx)"
2268
+ >
2269
+ <FilePlus className="w-3 h-3" />
2270
+ </button>
2271
+ )}
2272
+ </div>
2273
+ {/* Tree nodes */}
2274
+ <div className="flex-1 py-1">
2275
+ {(() => {
2276
+ // Build a folder → file[] map, plus root-level files
2277
+ const allFiles = Object.keys(reactFiles).sort(
2278
+ (a, b) => {
2279
+ const ad = a.split("/").length;
2280
+ const bd = b.split("/").length;
2281
+ return ad !== bd ? ad - bd : a.localeCompare(b);
2282
+ },
2283
+ );
2284
+ // Collect unique top-level folders (first path segment for nested files)
2285
+ const folders = Array.from(
2286
+ new Set(
2287
+ allFiles
2288
+ .filter((f) => f.includes("/"))
2289
+ .map((f) => f.split("/")[0]),
2290
+ ),
2291
+ ).sort();
2292
+ const rootFiles = allFiles.filter(
2293
+ (f) => !f.includes("/"),
2294
+ );
2295
+
2296
+ const fileIcon = (name: string) => {
2297
+ if (name.endsWith(".tsx") || name.endsWith(".jsx"))
2298
+ return (
2299
+ <span className="text-cyan-400 mr-1 text-[9px]">
2300
+
2301
+ </span>
2302
+ );
2303
+ if (name.endsWith(".ts") || name.endsWith(".js"))
2304
+ return (
2305
+ <span className="text-yellow-400 mr-1 text-[9px]">
2306
+ JS
2307
+ </span>
2308
+ );
2309
+ return (
2310
+ <span className="text-slate-500 mr-1 text-[9px]">
2311
+ f
2312
+ </span>
2313
+ );
2314
+ };
2315
+
2316
+ const renderFile = (path: string, indent = 0) => (
2317
+ <div key={path} className="group flex items-center">
2318
+ <button
2319
+ type="button"
2320
+ onClick={() => {
2321
+ setReactActiveFile(path);
2322
+ setReactClientTab("edit");
2323
+ }}
2324
+ style={{ paddingLeft: `${8 + indent * 10}px` }}
2325
+ className={`flex-1 flex items-center gap-0.5 py-0.5 pr-1 text-left text-[10px] font-mono truncate transition-colors ${
2326
+ path === reactActiveFile &&
2327
+ reactClientTab === "edit"
2328
+ ? "bg-slate-700 text-slate-100"
2329
+ : "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
2330
+ }`}
2331
+ title={path}
2332
+ >
2333
+ {fileIcon(path.split("/").pop() ?? path)}
2334
+ <span className="truncate">
2335
+ {path.split("/").pop()}
2336
+ </span>
2337
+ </button>
2338
+ <button
2339
+ type="button"
2340
+ onClick={() => {
2341
+ if (Object.keys(reactFiles).length <= 1)
2342
+ return;
2343
+ const remaining = Object.keys(
2344
+ reactFiles,
2345
+ ).filter((f) => f !== path);
2346
+ setReactFiles((prev) => {
2347
+ const next = { ...prev };
2348
+ delete next[path];
2349
+ return next;
2350
+ });
2351
+ if (reactActiveFile === path)
2352
+ setReactActiveFile(remaining[0] ?? "");
2353
+ }}
2354
+ className="opacity-0 group-hover:opacity-100 p-0.5 mr-1 rounded text-slate-600 hover:text-red-400 transition-all shrink-0"
2355
+ title="Delete file"
2356
+ >
2357
+ <X className="w-2.5 h-2.5" />
2358
+ </button>
2359
+ </div>
2360
+ );
2361
+
2362
+ const renderFolder = (folder: string) => {
2363
+ const isOpen = !collapsedFolders.has(folder);
2364
+ const children = allFiles.filter((f) => {
2365
+ const parts = f.split("/");
2366
+ return parts[0] === folder && parts.length >= 2;
2367
+ });
2368
+ // Build sub-folder groups within this folder
2369
+ const subFolders = Array.from(
2370
+ new Set(
2371
+ children
2372
+ .filter((f) => f.split("/").length > 2)
2373
+ .map((f) =>
2374
+ f.split("/").slice(0, 2).join("/"),
2375
+ ),
2376
+ ),
2377
+ ).sort();
2378
+ const directFiles = children.filter(
2379
+ (f) => f.split("/").length === 2,
2380
+ );
2381
+
2382
+ return (
2383
+ <div key={folder}>
2384
+ {/* Folder row */}
2385
+ <button
2386
+ type="button"
2387
+ onClick={() =>
2388
+ setCollapsedFolders((prev) => {
2389
+ const next = new Set(prev);
2390
+ if (next.has(folder)) next.delete(folder);
2391
+ else next.add(folder);
2392
+ return next;
2393
+ })
2394
+ }
2395
+ className="w-full flex items-center gap-0.5 px-2 py-0.5 text-left text-[10px] font-mono text-slate-300 hover:bg-slate-800 transition-colors select-none"
2396
+ >
2397
+ {isOpen ? (
2398
+ <ChevronDown className="w-2.5 h-2.5 shrink-0 text-slate-500" />
2399
+ ) : (
2400
+ <ChevronRight className="w-2.5 h-2.5 shrink-0 text-slate-500" />
2401
+ )}
2402
+ <span className="text-yellow-300/80 mr-0.5">
2403
+ 📁
2404
+ </span>
2405
+ <span className="truncate">{folder}/</span>
2406
+ </button>
2407
+ {/* Children */}
2408
+ {isOpen && (
2409
+ <div>
2410
+ {subFolders.map((sf) => {
2411
+ const sfIsOpen =
2412
+ !collapsedFolders.has(sf);
2413
+ const sfChildren = allFiles.filter(
2414
+ (f) =>
2415
+ f.startsWith(sf + "/") &&
2416
+ f.split("/").length ===
2417
+ sf.split("/").length + 1,
2418
+ );
2419
+ const sfKey = sf.split("/").pop() ?? sf;
2420
+ return (
2421
+ <div key={sf}>
2422
+ <button
2423
+ type="button"
2424
+ onClick={() =>
2425
+ setCollapsedFolders((prev) => {
2426
+ const next = new Set(prev);
2427
+ if (next.has(sf))
2428
+ next.delete(sf);
2429
+ else next.add(sf);
2430
+ return next;
2431
+ })
2432
+ }
2433
+ className="w-full flex items-center gap-0.5 pl-[18px] pr-2 py-0.5 text-left text-[10px] font-mono text-slate-300 hover:bg-slate-800 transition-colors select-none"
2434
+ >
2435
+ {sfIsOpen ? (
2436
+ <ChevronDown className="w-2.5 h-2.5 shrink-0 text-slate-500" />
2437
+ ) : (
2438
+ <ChevronRight className="w-2.5 h-2.5 shrink-0 text-slate-500" />
2439
+ )}
2440
+ <span className="text-yellow-300/80 mr-0.5">
2441
+ 📁
2442
+ </span>
2443
+ <span className="truncate">
2444
+ {sfKey}/
2445
+ </span>
2446
+ </button>
2447
+ {sfIsOpen &&
2448
+ sfChildren.map((f) =>
2449
+ renderFile(f, 3),
2450
+ )}
2451
+ </div>
2452
+ );
2453
+ })}
2454
+ {directFiles.map((f) => renderFile(f, 1))}
2455
+ </div>
2456
+ )}
2457
+ </div>
2458
+ );
2459
+ };
2460
+
2461
+ return (
2462
+ <>
2463
+ {folders.map(renderFolder)}
2464
+ {rootFiles.map((f) => renderFile(f, 0))}
2465
+ </>
2466
+ );
2467
+ })()}
2468
+ </div>
2469
+ </div>
2470
+ )}
2471
+
2472
+ {/* ── Editor / Preview area ── */}
2473
+ <div
2474
+ className={`${clientType === "nextjs" ? "flex-1 min-w-0 relative" : "absolute inset-0"}`}
1436
2475
  >
1437
- {clientRunning ? (
1438
- <Loader2 className="w-3 h-3 animate-spin" />
2476
+ {clientType === "script" ? (
2477
+ <SyntaxEditor
2478
+ value={clientCode}
2479
+ onChange={setClientCode}
2480
+ onCtrlEnter={() => {
2481
+ if (serverRunning) void runClient();
2482
+ }}
2483
+ language={clientLang}
2484
+ fontSize="12px"
2485
+ focusRingClass="ring-cyan-500/30"
2486
+ placeholder={
2487
+ "// SANDBOX_URL is injected automatically\n// Start the server first, then Ctrl+Enter to run"
2488
+ }
2489
+ />
2490
+ ) : reactClientTab === "edit" ? (
2491
+ <SyntaxEditor
2492
+ key={reactActiveFile}
2493
+ value={reactFiles[reactActiveFile] ?? ""}
2494
+ onChange={(val) =>
2495
+ setReactFiles((prev) => ({
2496
+ ...prev,
2497
+ [reactActiveFile]: val,
2498
+ }))
2499
+ }
2500
+ language={
2501
+ reactActiveFile.endsWith(".ts") ||
2502
+ reactActiveFile.endsWith(".tsx")
2503
+ ? "typescript"
2504
+ : "javascript"
2505
+ }
2506
+ fontSize="12px"
2507
+ focusRingClass="ring-cyan-500/30"
2508
+ placeholder={`// ${reactActiveFile}\n`}
2509
+ />
1439
2510
  ) : (
1440
- <Play className="w-3 h-3" />
2511
+ // Preview area — URL bar for Next.js, plain iframe for React
2512
+ <div className="w-full h-full flex flex-col">
2513
+ {clientType === "nextjs" && (
2514
+ <div className="flex items-center gap-1 px-2 py-1 bg-slate-800 border-b border-slate-700 shrink-0">
2515
+ {/* Back */}
2516
+ <button
2517
+ type="button"
2518
+ disabled={reactNavIndex <= 0}
2519
+ onClick={() => {
2520
+ const idx = reactNavIndex - 1;
2521
+ const path = reactNavHistory[idx] ?? "/";
2522
+ setReactNavIndex(idx);
2523
+ setReactPreviewPath(path);
2524
+ setReactNavInput(path);
2525
+ if (nxSandboxUrl) {
2526
+ if (nxIframeRef.current)
2527
+ nxIframeRef.current.src =
2528
+ nxSandboxUrl + path;
2529
+ } else {
2530
+ refreshPreview(path);
2531
+ }
2532
+ }}
2533
+ className="p-0.5 rounded text-slate-500 hover:text-slate-200 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
2534
+ title="Back"
2535
+ >
2536
+ <ChevronLeft className="w-3.5 h-3.5" />
2537
+ </button>
2538
+ {/* Forward */}
2539
+ <button
2540
+ type="button"
2541
+ disabled={
2542
+ reactNavIndex >= reactNavHistory.length - 1
2543
+ }
2544
+ onClick={() => {
2545
+ const idx = reactNavIndex + 1;
2546
+ const path = reactNavHistory[idx] ?? "/";
2547
+ setReactNavIndex(idx);
2548
+ setReactPreviewPath(path);
2549
+ setReactNavInput(path);
2550
+ if (nxSandboxUrl) {
2551
+ if (nxIframeRef.current)
2552
+ nxIframeRef.current.src =
2553
+ nxSandboxUrl + path;
2554
+ } else {
2555
+ refreshPreview(path);
2556
+ }
2557
+ }}
2558
+ className="p-0.5 rounded text-slate-500 hover:text-slate-200 disabled:opacity-30 disabled:cursor-not-allowed transition-colors shrink-0"
2559
+ title="Forward"
2560
+ >
2561
+ <ChevronRight className="w-3.5 h-3.5" />
2562
+ </button>
2563
+ {/* Refresh */}
2564
+ <button
2565
+ type="button"
2566
+ onClick={() => {
2567
+ if (nxSandboxUrl && nxIframeRef.current) {
2568
+ nxIframeRef.current.src =
2569
+ nxIframeRef.current.src;
2570
+ } else {
2571
+ refreshPreview();
2572
+ }
2573
+ }}
2574
+ className="p-0.5 rounded text-slate-500 hover:text-slate-200 transition-colors shrink-0"
2575
+ title="Refresh"
2576
+ >
2577
+ <svg
2578
+ className="w-3 h-3"
2579
+ viewBox="0 0 16 16"
2580
+ fill="currentColor"
2581
+ >
2582
+ <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" />
2583
+ </svg>
2584
+ </button>
2585
+ {/* URL bar */}
2586
+ <form
2587
+ 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"
2588
+ onSubmit={(e) => {
2589
+ e.preventDefault();
2590
+ const path = reactNavInput.startsWith("/")
2591
+ ? reactNavInput
2592
+ : "/" + reactNavInput;
2593
+ setReactPreviewPath(path);
2594
+ setReactNavHistory((prev) => [
2595
+ ...prev.slice(0, reactNavIndex + 1),
2596
+ path,
2597
+ ]);
2598
+ setReactNavIndex((i) => i + 1);
2599
+ if (nxSandboxUrl) {
2600
+ if (nxIframeRef.current)
2601
+ nxIframeRef.current.src =
2602
+ nxSandboxUrl + path;
2603
+ } else {
2604
+ navigatePreview(path);
2605
+ }
2606
+ }}
2607
+ >
2608
+ <span className="text-slate-600 text-[9px] font-mono select-none shrink-0">
2609
+ {nxSandboxUrl
2610
+ ? `localhost:${nxSandboxUrl.split(":").pop()}`
2611
+ : "localhost:3000"}
2612
+ </span>
2613
+ <input
2614
+ value={reactNavInput}
2615
+ onChange={(e) =>
2616
+ setReactNavInput(e.target.value)
2617
+ }
2618
+ onFocus={(e) => e.target.select()}
2619
+ className="flex-1 bg-transparent text-[11px] font-mono text-slate-200 outline-none placeholder-slate-600 min-w-0"
2620
+ placeholder="/"
2621
+ spellCheck={false}
2622
+ />
2623
+ </form>
2624
+ {/* Status indicator */}
2625
+ {nxSandboxUrl ? (
2626
+ <span className="text-[9px] font-mono text-green-400 shrink-0">
2627
+ ● live
2628
+ </span>
2629
+ ) : (
2630
+ (() => {
2631
+ const resolved = resolveNextjsEntry(
2632
+ reactFiles,
2633
+ reactPreviewPath,
2634
+ );
2635
+ return resolved ? (
2636
+ <span
2637
+ className="text-[9px] font-mono text-slate-600 truncate max-w-[100px] shrink-0"
2638
+ title={resolved}
2639
+ >
2640
+ → {resolved}
2641
+ </span>
2642
+ ) : (
2643
+ <span className="text-[9px] font-mono text-red-400 shrink-0">
2644
+ 404
2645
+ </span>
2646
+ );
2647
+ })()
2648
+ )}
2649
+ </div>
2650
+ )}
2651
+ {/* Error banner */}
2652
+ {nxError && (
2653
+ <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">
2654
+ {nxError}
2655
+ </div>
2656
+ )}
2657
+ {/* Starting overlay */}
2658
+ {nxStarting && (
2659
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-400 text-sm bg-slate-950">
2660
+ <Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
2661
+ <p className="text-[12px]">
2662
+ Starting Next.js dev server…
2663
+ </p>
2664
+ <p className="text-[10px] text-slate-600">
2665
+ This takes ~10 seconds on the first run
2666
+ </p>
2667
+ </div>
2668
+ )}
2669
+ {/* Real Next.js iframe */}
2670
+ {!nxStarting && nxSandboxUrl && (
2671
+ <iframe
2672
+ ref={nxIframeRef}
2673
+ src={nxSandboxUrl + reactPreviewPath}
2674
+ className="flex-1 min-h-0 w-full border-0 bg-white"
2675
+ title="Next.js Preview"
2676
+ onLoad={() => {
2677
+ // Try to read the iframe path (may be blocked cross-origin)
2678
+ try {
2679
+ const p =
2680
+ nxIframeRef.current?.contentWindow?.location
2681
+ .pathname;
2682
+ if (p) {
2683
+ setReactPreviewPath(p);
2684
+ setReactNavInput(p);
2685
+ }
2686
+ } catch {
2687
+ // cross-origin — ignore
2688
+ }
2689
+ }}
2690
+ />
2691
+ )}
2692
+ {/* Simulation iframe (when no real server) */}
2693
+ {!nxStarting && !nxSandboxUrl && (
2694
+ <iframe
2695
+ srcDoc={reactPreviewSrc ?? ""}
2696
+ sandbox="allow-scripts"
2697
+ className="flex-1 min-h-0 w-full border-0 bg-white"
2698
+ title="React Preview"
2699
+ />
2700
+ )}
2701
+ </div>
1441
2702
  )}
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
- />
2703
+ </div>
1459
2704
  </div>
1460
2705
  </div>
1461
2706
  )}
@@ -1479,18 +2724,81 @@ export default function CodeRunnerModal() {
1479
2724
  className="bg-slate-950 flex flex-col overflow-hidden shrink-0 transition-[height]"
1480
2725
  style={{ height: outputCollapsed ? 0 : sbxOutputH }}
1481
2726
  >
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">
2727
+ {/* Tab bar */}
2728
+ <div className="flex items-center gap-0 px-1 bg-slate-900 border-b border-slate-800 shrink-0">
2729
+ <button
2730
+ type="button"
2731
+ onClick={() => setSbxBottomTab("output")}
2732
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider font-medium border-b-2 transition-colors ${
2733
+ sbxBottomTab === "output"
2734
+ ? "border-emerald-500 text-emerald-300"
2735
+ : "border-transparent text-slate-500 hover:text-slate-300"
2736
+ }`}
2737
+ >
2738
+ {(serverStarting || clientRunning) &&
2739
+ sbxBottomTab !== "output" ? (
2740
+ <Loader2 className="w-3 h-3 animate-spin" />
2741
+ ) : null}
1484
2742
  Output
1485
- </span>
1486
- {(serverStarting || clientRunning) && (
1487
- <Loader2 className="w-3 h-3 text-emerald-400 animate-spin" />
2743
+ </button>
2744
+ <button
2745
+ type="button"
2746
+ onClick={() => {
2747
+ setSbxBottomTab("chat");
2748
+ setTimeout(() => sbxChatInputRef.current?.focus(), 30);
2749
+ }}
2750
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider font-medium border-b-2 transition-colors ${
2751
+ sbxBottomTab === "chat"
2752
+ ? "border-violet-500 text-violet-300"
2753
+ : "border-transparent text-slate-500 hover:text-slate-300"
2754
+ }`}
2755
+ >
2756
+ <MessageSquare className="w-3 h-3" />
2757
+ Chat
2758
+ </button>
2759
+ <div className="flex-1" />
2760
+ {sbxBottomTab === "output" &&
2761
+ (serverStarting || clientRunning) && (
2762
+ <Loader2 className="w-3 h-3 text-emerald-400 animate-spin mr-1" />
2763
+ )}
2764
+ {sbxBottomTab === "output" && sandboxOutput.length > 0 && (
2765
+ <div className="flex items-center gap-1 mr-1">
2766
+ <button
2767
+ type="button"
2768
+ onClick={() =>
2769
+ navigator.clipboard.writeText(
2770
+ sandboxOutput.map((l) => l.text).join("\n"),
2771
+ )
2772
+ }
2773
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
2774
+ title="Copy output"
2775
+ >
2776
+ <Copy className="w-3 h-3" />
2777
+ </button>
2778
+ <button
2779
+ type="button"
2780
+ onClick={() => setSandboxOutput([])}
2781
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
2782
+ title="Clear output"
2783
+ >
2784
+ <Trash2 className="w-3 h-3" />
2785
+ </button>
2786
+ </div>
2787
+ )}
2788
+ {sbxBottomTab === "chat" && sbxChatMessages.length > 0 && (
2789
+ <button
2790
+ type="button"
2791
+ onClick={() => setSbxChatMessages([])}
2792
+ className="mr-2 text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
2793
+ >
2794
+ clear
2795
+ </button>
1488
2796
  )}
1489
2797
  <button
1490
2798
  type="button"
1491
- className="ml-auto p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
2799
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors mr-1"
1492
2800
  onClick={() => setOutputCollapsed((v) => !v)}
1493
- title={outputCollapsed ? "Expand output" : "Collapse output"}
2801
+ title={outputCollapsed ? "Expand" : "Collapse"}
1494
2802
  >
1495
2803
  {outputCollapsed ? (
1496
2804
  <ChevronUp className="w-3 h-3" />
@@ -1499,48 +2807,221 @@ export default function CodeRunnerModal() {
1499
2807
  )}
1500
2808
  </button>
1501
2809
  </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"
2810
+
2811
+ {/* Output tab */}
2812
+ {sbxBottomTab === "output" && (
2813
+ <div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[12px] leading-relaxed">
2814
+ {sandboxOutput.length === 0 &&
2815
+ !serverStarting &&
2816
+ !clientRunning && (
2817
+ <span className="text-slate-600">
2818
+ Start the server, then run the client
2819
+ </span>
2820
+ )}
2821
+ {sandboxOutput.map((line, i) => (
2822
+ <div key={i} className="flex items-start gap-2">
2823
+ <span
2824
+ className={`shrink-0 text-[9px] font-bold mt-0.5 w-7 text-right ${
2825
+ line.source === "server"
2826
+ ? "text-emerald-600"
2827
+ : line.source === "client"
2828
+ ? "text-cyan-600"
2829
+ : "text-slate-700"
2830
+ }`}
2831
+ >
2832
+ {line.source === "server"
2833
+ ? "srv"
1516
2834
  : 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
- }
2835
+ ? "cli"
2836
+ : "···"}
2837
+ </span>
2838
+ <span
2839
+ className={
2840
+ line.kind === "stderr"
2841
+ ? "text-red-400 whitespace-pre-wrap"
2842
+ : line.kind === "warn"
2843
+ ? "text-amber-400 whitespace-pre-wrap"
2844
+ : line.kind === "info"
2845
+ ? "text-slate-500 italic whitespace-pre-wrap"
2846
+ : "text-slate-200 whitespace-pre-wrap"
2847
+ }
2848
+ >
2849
+ {line.text}
2850
+ </span>
2851
+ </div>
2852
+ ))}
2853
+ <div ref={outputEndRef} />
2854
+ </div>
2855
+ )}
2856
+
2857
+ {/* Chat tab */}
2858
+ {sbxBottomTab === "chat" && (
2859
+ <>
2860
+ <div
2861
+ ref={sbxChatScrollRef}
2862
+ className="flex-1 overflow-y-auto px-3 py-2 space-y-3"
2863
+ >
2864
+ {sbxChatMessages.length === 0 && (
2865
+ <p className="text-xs text-slate-600 pt-1">
2866
+ Ask anything about your code —{" "}
2867
+ {clientType === "react" || clientType === "nextjs" ? (
2868
+ <span className="text-slate-500">
2869
+ "Why does my useEffect run twice?"
2870
+ </span>
2871
+ ) : (
2872
+ <span className="text-slate-500">
2873
+ "Why is fetch failing?"
2874
+ </span>
2875
+ )}
2876
+ </p>
2877
+ )}
2878
+ {sbxChatMessages.map((msg) => (
2879
+ <div
2880
+ key={msg.id}
2881
+ className={`flex flex-col gap-0.5 ${msg.role === "user" ? "items-end" : "items-start"}`}
2882
+ >
2883
+ <div
2884
+ className={`max-w-[85%] rounded-xl px-3 py-2 text-xs leading-5 ${
2885
+ msg.role === "user"
2886
+ ? "bg-violet-600/30 text-violet-100 whitespace-pre-wrap"
2887
+ : "bg-slate-800 text-slate-200 prose prose-invert prose-xs max-w-none"
2888
+ }`}
2889
+ >
2890
+ {msg.role === "user" ? (
2891
+ msg.content
2892
+ ) : msg.content ? (
2893
+ <ReactMarkdown
2894
+ remarkPlugins={[remarkGfm]}
2895
+ components={{
2896
+ code({ className, children, ...props }) {
2897
+ const isBlock =
2898
+ className?.startsWith("language-");
2899
+ return isBlock ? (
2900
+ <pre className="bg-slate-900/80 rounded p-2 overflow-x-auto my-1">
2901
+ <code
2902
+ className={`${className ?? ""} text-[11px]`}
2903
+ {...props}
2904
+ >
2905
+ {children}
2906
+ </code>
2907
+ </pre>
2908
+ ) : (
2909
+ <code
2910
+ className="bg-slate-900/60 px-1 rounded text-violet-300 text-[11px]"
2911
+ {...props}
2912
+ >
2913
+ {children}
2914
+ </code>
2915
+ );
2916
+ },
2917
+ p({ children }) {
2918
+ return (
2919
+ <p className="mb-1 last:mb-0">{children}</p>
2920
+ );
2921
+ },
2922
+ ul({ children }) {
2923
+ return (
2924
+ <ul className="list-disc list-inside mb-1 space-y-0.5">
2925
+ {children}
2926
+ </ul>
2927
+ );
2928
+ },
2929
+ ol({ children }) {
2930
+ return (
2931
+ <ol className="list-decimal list-inside mb-1 space-y-0.5">
2932
+ {children}
2933
+ </ol>
2934
+ );
2935
+ },
2936
+ h2({ children }) {
2937
+ return (
2938
+ <h2 className="text-xs font-semibold text-slate-200 mt-2 mb-0.5">
2939
+ {children}
2940
+ </h2>
2941
+ );
2942
+ },
2943
+ h3({ children }) {
2944
+ return (
2945
+ <h3 className="text-xs font-semibold text-slate-300 mt-1.5 mb-0.5">
2946
+ {children}
2947
+ </h3>
2948
+ );
2949
+ },
2950
+ }}
2951
+ >
2952
+ {msg.content}
2953
+ </ReactMarkdown>
2954
+ ) : (
2955
+ <span className="flex items-center gap-1.5 text-slate-500">
2956
+ <Loader2 className="w-3 h-3 animate-spin" />{" "}
2957
+ thinking…
2958
+ </span>
2959
+ )}
2960
+ </div>
2961
+ {msg.role === "assistant" && msg.content && (
2962
+ <button
2963
+ onClick={() => {
2964
+ void navigator.clipboard
2965
+ .writeText(msg.content)
2966
+ .then(() => {
2967
+ setSbxChatCopiedId(msg.id);
2968
+ setTimeout(
2969
+ () => setSbxChatCopiedId(null),
2970
+ 1800,
2971
+ );
2972
+ });
2973
+ }}
2974
+ className="flex items-center gap-1 text-[10px] text-slate-700 hover:text-slate-400 transition-colors px-1"
2975
+ title="Copy response"
2976
+ >
2977
+ {sbxChatCopiedId === msg.id ? (
2978
+ <>
2979
+ <ClipboardCheck className="w-3 h-3 text-emerald-400" />
2980
+ <span className="text-emerald-400">Copied</span>
2981
+ </>
2982
+ ) : (
2983
+ <>
2984
+ <Clipboard className="w-3 h-3" />
2985
+ <span>Copy</span>
2986
+ </>
2987
+ )}
2988
+ </button>
2989
+ )}
2990
+ </div>
2991
+ ))}
2992
+ </div>
2993
+ <div className="flex items-end gap-1.5 px-3 py-2 border-t border-slate-800 bg-slate-900/60 shrink-0">
2994
+ <textarea
2995
+ ref={sbxChatInputRef}
2996
+ rows={1}
2997
+ value={sbxChatInput}
2998
+ onChange={(e) => setSbxChatInput(e.target.value)}
2999
+ onKeyDown={(e) => {
3000
+ if (e.key === "Enter" && !e.shiftKey) {
3001
+ e.preventDefault();
3002
+ void handleSbxChatSend();
3003
+ }
3004
+ }}
3005
+ placeholder={`Ask about your ${clientType === "react" ? "React" : clientType === "nextjs" ? "Next.js" : "sandbox"} code…`}
3006
+ disabled={sbxChatLoading}
3007
+ className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-600 outline-none resize-none disabled:opacity-50 max-h-20"
3008
+ />
3009
+ <button
3010
+ type="button"
3011
+ onClick={() => void handleSbxChatSend()}
3012
+ disabled={sbxChatLoading || !sbxChatInput.trim()}
3013
+ className="p-1 rounded text-slate-600 hover:text-violet-400 hover:bg-violet-500/10 disabled:opacity-40 transition-colors shrink-0"
3014
+ title="Send (Enter)"
1537
3015
  >
1538
- {line.text}
1539
- </span>
3016
+ {sbxChatLoading ? (
3017
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
3018
+ ) : (
3019
+ <Send className="w-3.5 h-3.5" />
3020
+ )}
3021
+ </button>
1540
3022
  </div>
1541
- ))}
1542
- <div ref={outputEndRef} />
1543
- </div>
3023
+ </>
3024
+ )}
1544
3025
  </div>
1545
3026
  </div>
1546
3027
  )}