create-interview-cockpit 0.6.0 → 0.8.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.
@@ -43,10 +43,18 @@ import {
43
43
  generatePreviewHTML,
44
44
  defaultForType,
45
45
  resolveNextjsEntry,
46
+ type FrontendLabType,
46
47
  } from "../reactLab";
47
48
  import {
49
+ fetchModuleFederationGeneratedFile,
50
+ fetchModuleFederationGeneratedFiles,
51
+ fetchModuleFederationStatus,
52
+ startModuleFederationSandbox,
48
53
  startNextjsSandbox,
54
+ stopModuleFederationSandbox,
55
+ streamModuleFederationCommand,
49
56
  updateNextjsFiles,
57
+ updateModuleFederationFiles,
50
58
  stopNextjsSandbox,
51
59
  } from "../api";
52
60
  import ReactMarkdown from "react-markdown";
@@ -67,6 +75,7 @@ interface OutputLine {
67
75
 
68
76
  const LANG_OPTIONS = ["typescript", "javascript"] as const;
69
77
  type Lang = (typeof LANG_OPTIONS)[number];
78
+ type FrontendClientType = "script" | FrontendLabType;
70
79
 
71
80
  // ── Sandbox default snippets ─────────────────────────────────────────
72
81
  const DEFAULT_SERVER_CODE = `import express from 'express';
@@ -202,6 +211,74 @@ function SyntaxEditor({
202
211
  );
203
212
  }
204
213
 
214
+ interface FileTreeNode {
215
+ name: string;
216
+ path: string;
217
+ children: FileTreeNode[];
218
+ files: string[];
219
+ }
220
+
221
+ function buildFileTree(paths: string[]): FileTreeNode {
222
+ const root: FileTreeNode = {
223
+ name: "",
224
+ path: "",
225
+ children: [],
226
+ files: [],
227
+ };
228
+
229
+ for (const filePath of paths) {
230
+ const parts = filePath.split("/");
231
+ let node = root;
232
+
233
+ for (let index = 0; index < parts.length - 1; index += 1) {
234
+ const name = parts[index];
235
+ const path = parts.slice(0, index + 1).join("/");
236
+ let child = node.children.find((entry) => entry.path === path);
237
+
238
+ if (!child) {
239
+ child = {
240
+ name,
241
+ path,
242
+ children: [],
243
+ files: [],
244
+ };
245
+ node.children.push(child);
246
+ }
247
+
248
+ node = child;
249
+ }
250
+
251
+ node.files.push(filePath);
252
+ }
253
+
254
+ return root;
255
+ }
256
+
257
+ function isModuleFederationGeneratedPath(filePath: string): boolean {
258
+ return /(^|\/)dist\//.test(filePath);
259
+ }
260
+
261
+ function getModuleFederationCommandRoots(
262
+ files: Record<string, string>,
263
+ ): string[] {
264
+ const roots = new Set<string>(["."]);
265
+
266
+ for (const filePath of Object.keys(files)) {
267
+ const match = filePath.match(/^(apps\/[^/]+)/);
268
+ if (match) {
269
+ roots.add(match[1]);
270
+ }
271
+ }
272
+
273
+ return Array.from(roots).sort((a, b) => {
274
+ if (a === ".") return -1;
275
+ if (b === ".") return 1;
276
+ return a.localeCompare(b);
277
+ });
278
+ }
279
+
280
+ type SbxBottomTab = "output" | "console" | "chat";
281
+
205
282
  export default function CodeRunnerModal() {
206
283
  const {
207
284
  closeCodeRunner,
@@ -246,9 +323,7 @@ export default function CodeRunnerModal() {
246
323
  const [clientRunning, setClientRunning] = useState(false);
247
324
 
248
325
  // ── React/Next.js client state ──────────────────────────────
249
- const [clientType, setClientType] = useState<"script" | "react" | "nextjs">(
250
- "script",
251
- );
326
+ const [clientType, setClientType] = useState<FrontendClientType>("script");
252
327
  const [reactFiles, setReactFiles] = useState<Record<string, string>>({});
253
328
  const [reactActiveFile, setReactActiveFile] = useState<string>("");
254
329
  const [reactClientTab, setReactClientTab] = useState<"edit" | "preview">(
@@ -267,14 +342,29 @@ export default function CodeRunnerModal() {
267
342
  const [nxStarting, setNxStarting] = useState(false);
268
343
  const [nxError, setNxError] = useState<string | null>(null);
269
344
  const nxIframeRef = useRef<HTMLIFrameElement>(null);
345
+ const [mfSandboxId, setMfSandboxId] = useState<string | null>(null);
346
+ const [mfHostUrl, setMfHostUrl] = useState<string | null>(null);
347
+ const [mfAppUrls, setMfAppUrls] = useState<Record<string, string>>({});
348
+ const [mfStarting, setMfStarting] = useState(false);
349
+ const [mfError, setMfError] = useState<string | null>(null);
350
+ const [mfPreviewApp, setMfPreviewApp] = useState("host");
351
+ const [mfConsoleOutput, setMfConsoleOutput] = useState<OutputLine[]>([]);
352
+ const [mfConsoleCommand, setMfConsoleCommand] = useState("npm run build");
353
+ const [mfConsoleCwd, setMfConsoleCwd] = useState("apps/host");
354
+ const [mfConsoleRunning, setMfConsoleRunning] = useState(false);
355
+ const [mfGeneratedFiles, setMfGeneratedFiles] = useState<string[]>([]);
356
+ const [mfGeneratedFileContents, setMfGeneratedFileContents] = useState<
357
+ Record<string, string>
358
+ >({});
359
+ const [mfLoadingFile, setMfLoadingFile] = useState<string | null>(null);
270
360
  // Simulated URL bar state for Next.js mode
271
361
  const [reactPreviewPath, setReactPreviewPath] = useState("/");
272
362
  const [reactNavInput, setReactNavInput] = useState("/");
273
363
  const [reactNavHistory, setReactNavHistory] = useState<string[]>(["/"]);
274
364
  const [reactNavIndex, setReactNavIndex] = useState(0);
275
365
 
276
- // ── Sandbox output tab ("output" | "chat") ──────────────────
277
- const [sbxBottomTab, setSbxBottomTab] = useState<"output" | "chat">("output");
366
+ // ── Sandbox output tab ("output" | "console" | "chat") ─────────────
367
+ const [sbxBottomTab, setSbxBottomTab] = useState<SbxBottomTab>("output");
278
368
 
279
369
  // ── Sandbox panel sizes ─────────────────────────────────────────
280
370
  // sbxSplit: server pane width as % of the editor row (0–100)
@@ -367,7 +457,9 @@ export default function CodeRunnerModal() {
367
457
  ? "react"
368
458
  : clientType === "nextjs"
369
459
  ? "nextjs"
370
- : "sandbox";
460
+ : clientType === "module-federation"
461
+ ? "module-federation"
462
+ : "sandbox";
371
463
  const payload = JSON.stringify(
372
464
  clientType === "script"
373
465
  ? { serverCode, serverLang, clientCode, clientLang }
@@ -444,7 +536,9 @@ export default function CodeRunnerModal() {
444
536
  };
445
537
 
446
538
  const outputEndRef = useRef<HTMLDivElement>(null);
539
+ const mfConsoleEndRef = useRef<HTMLDivElement>(null);
447
540
  const nameInputRef = useRef<HTMLInputElement>(null);
541
+ const mfGeneratedFileRequestRef = useRef<string | null>(null);
448
542
  // Tracks how many server log lines have already been flushed to sandboxOutput
449
543
  const sandboxLogOffsetRef = useRef(0);
450
544
  // Stable ref so unmount cleanup can stop sandbox without stale closure
@@ -474,10 +568,9 @@ export default function CodeRunnerModal() {
474
568
  setActiveSandboxId(runnerInitialSandbox.fileId ?? null);
475
569
  // Restore client type and React/Next.js files
476
570
  const ct =
477
- (runnerInitialSandbox.clientType as "script" | "react" | "nextjs") ??
478
- "script";
571
+ (runnerInitialSandbox.clientType as FrontendClientType) ?? "script";
479
572
  setClientType(ct);
480
- if (ct === "react" || ct === "nextjs") {
573
+ if (ct !== "script") {
481
574
  if (
482
575
  runnerInitialSandbox.reactFiles &&
483
576
  Object.keys(runnerInitialSandbox.reactFiles).length > 0
@@ -495,6 +588,17 @@ export default function CodeRunnerModal() {
495
588
  }
496
589
  setReactPreviewSrc(null);
497
590
  setReactClientTab("edit");
591
+ if (ct === "module-federation") {
592
+ setServerCollapsed(true);
593
+ setClientCollapsed(false);
594
+ setMfPreviewApp("host");
595
+ setMfConsoleCommand("npm run build");
596
+ setMfConsoleCwd("apps/host");
597
+ setMfConsoleOutput([]);
598
+ setMfGeneratedFiles([]);
599
+ setMfGeneratedFileContents({});
600
+ setMfLoadingFile(null);
601
+ }
498
602
  }
499
603
  }, [runnerInitialSandbox]);
500
604
 
@@ -505,6 +609,20 @@ export default function CodeRunnerModal() {
505
609
  outputEndRef.current?.scrollIntoView({ behavior: "smooth" });
506
610
  }, [output]);
507
611
 
612
+ useEffect(() => {
613
+ if (sbxBottomTab === "console") {
614
+ mfConsoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
615
+ }
616
+ }, [mfConsoleOutput, mfConsoleRunning, sbxBottomTab]);
617
+
618
+ useEffect(() => {
619
+ if (clientType !== "module-federation") return;
620
+ const roots = getModuleFederationCommandRoots(reactFiles);
621
+ if (!roots.includes(mfConsoleCwd)) {
622
+ setMfConsoleCwd(roots.find((root) => root !== ".") ?? ".");
623
+ }
624
+ }, [clientType, reactFiles, mfConsoleCwd]);
625
+
508
626
  // ── Sandbox divider drag handlers ────────────────────────────────
509
627
  useEffect(() => {
510
628
  const onMove = (e: MouseEvent) => {
@@ -979,6 +1097,112 @@ export default function CodeRunnerModal() {
979
1097
  }
980
1098
  }, [nxStarting, reactFiles]);
981
1099
 
1100
+ const refreshModuleFederationGeneratedFiles = useCallback(
1101
+ async (sandboxId: string | null = mfSandboxId): Promise<string[]> => {
1102
+ if (!sandboxId) {
1103
+ setMfGeneratedFiles([]);
1104
+ setMfGeneratedFileContents({});
1105
+ setMfLoadingFile(null);
1106
+ return [];
1107
+ }
1108
+
1109
+ try {
1110
+ const files = await fetchModuleFederationGeneratedFiles(sandboxId);
1111
+ setMfGeneratedFiles(files);
1112
+ setMfGeneratedFileContents((prev) =>
1113
+ Object.fromEntries(
1114
+ Object.entries(prev).filter(([filePath]) =>
1115
+ files.includes(filePath),
1116
+ ),
1117
+ ),
1118
+ );
1119
+
1120
+ if (
1121
+ reactActiveFile &&
1122
+ !Object.prototype.hasOwnProperty.call(reactFiles, reactActiveFile) &&
1123
+ !files.includes(reactActiveFile)
1124
+ ) {
1125
+ setReactActiveFile(Object.keys(reactFiles)[0] ?? files[0] ?? "");
1126
+ }
1127
+
1128
+ return files;
1129
+ } catch (err: any) {
1130
+ setMfError(err?.message ?? String(err));
1131
+ return [];
1132
+ }
1133
+ },
1134
+ [mfSandboxId, reactActiveFile, reactFiles],
1135
+ );
1136
+
1137
+ const startModuleFederationServer = useCallback(async () => {
1138
+ if (mfStarting) return;
1139
+ setMfStarting(true);
1140
+ setMfError(null);
1141
+ setMfGeneratedFiles([]);
1142
+ setMfGeneratedFileContents({});
1143
+ setMfLoadingFile(null);
1144
+ setMfConsoleOutput([]);
1145
+ setSbxBottomTab("output");
1146
+ setSandboxOutput([
1147
+ {
1148
+ kind: "info",
1149
+ text: "Installing dependencies and starting webpack apps…",
1150
+ source: "server",
1151
+ },
1152
+ ]);
1153
+ try {
1154
+ const info = await startModuleFederationSandbox(reactFiles);
1155
+ setMfSandboxId(info.id);
1156
+ setMfHostUrl(info.hostUrl);
1157
+ setMfAppUrls(info.appUrls);
1158
+ setMfPreviewApp(
1159
+ info.appUrls.host ? "host" : (Object.keys(info.appUrls)[0] ?? "host"),
1160
+ );
1161
+ setReactClientTab("preview");
1162
+ setServerCollapsed(true);
1163
+ setClientCollapsed(false);
1164
+ void refreshModuleFederationGeneratedFiles(info.id);
1165
+ setSandboxOutput((prev) => [
1166
+ ...prev,
1167
+ {
1168
+ kind: "info",
1169
+ text: `✓ Webpack host running at ${info.hostUrl}`,
1170
+ source: "server",
1171
+ },
1172
+ ]);
1173
+ } catch (err: any) {
1174
+ const message = err?.message ?? String(err);
1175
+ setMfError(message);
1176
+ setSandboxOutput((prev) => [
1177
+ ...prev,
1178
+ { kind: "stderr", text: message, source: "server" },
1179
+ ]);
1180
+ } finally {
1181
+ setMfStarting(false);
1182
+ }
1183
+ }, [mfStarting, reactFiles, refreshModuleFederationGeneratedFiles]);
1184
+
1185
+ const stopModuleFederationServer = useCallback(async () => {
1186
+ if (!mfSandboxId) return;
1187
+ await stopModuleFederationSandbox(mfSandboxId).catch(() => {});
1188
+ setMfSandboxId(null);
1189
+ setMfHostUrl(null);
1190
+ setMfAppUrls({});
1191
+ setMfError(null);
1192
+ setMfGeneratedFiles([]);
1193
+ setMfGeneratedFileContents({});
1194
+ setMfLoadingFile(null);
1195
+ setMfConsoleRunning(false);
1196
+ setSandboxOutput((prev) => [
1197
+ ...prev,
1198
+ {
1199
+ kind: "info",
1200
+ text: "Webpack module federation lab stopped.",
1201
+ source: "server",
1202
+ },
1203
+ ]);
1204
+ }, [mfSandboxId]);
1205
+
982
1206
  /** Push updated files to the running Next.js server (HMR picks them up). */
983
1207
  const pushNextjsFiles = useCallback(
984
1208
  async (files: Record<string, string>) => {
@@ -992,6 +1216,18 @@ export default function CodeRunnerModal() {
992
1216
  [nxSandboxId],
993
1217
  );
994
1218
 
1219
+ const pushModuleFederationFiles = useCallback(
1220
+ async (files: Record<string, string>) => {
1221
+ if (!mfSandboxId) return;
1222
+ try {
1223
+ await updateModuleFederationFiles(mfSandboxId, files);
1224
+ } catch (err: any) {
1225
+ setMfError(err?.message ?? String(err));
1226
+ }
1227
+ },
1228
+ [mfSandboxId],
1229
+ );
1230
+
995
1231
  // Auto-push file changes to the running Next.js server
996
1232
  const nxFilesRef = useRef(reactFiles);
997
1233
  useEffect(() => {
@@ -1000,6 +1236,196 @@ export default function CodeRunnerModal() {
1000
1236
  void pushNextjsFiles(reactFiles);
1001
1237
  }, [reactFiles, nxSandboxId, pushNextjsFiles]);
1002
1238
 
1239
+ const mfFilesRef = useRef(reactFiles);
1240
+ useEffect(() => {
1241
+ if (!mfSandboxId || reactFiles === mfFilesRef.current) return;
1242
+ mfFilesRef.current = reactFiles;
1243
+ void pushModuleFederationFiles(reactFiles);
1244
+ }, [reactFiles, mfSandboxId, pushModuleFederationFiles]);
1245
+
1246
+ useEffect(() => {
1247
+ if (!mfSandboxId) return;
1248
+ const interval = setInterval(async () => {
1249
+ try {
1250
+ const status = await fetchModuleFederationStatus(mfSandboxId);
1251
+ if (!status.running) {
1252
+ setMfSandboxId(null);
1253
+ setMfHostUrl(null);
1254
+ setMfAppUrls({});
1255
+ setMfError(null);
1256
+ setMfGeneratedFiles([]);
1257
+ setMfGeneratedFileContents({});
1258
+ setMfLoadingFile(null);
1259
+ setMfConsoleRunning(false);
1260
+ return;
1261
+ }
1262
+ if (status.hostUrl) setMfHostUrl(status.hostUrl);
1263
+ if (status.appUrls) {
1264
+ setMfAppUrls(status.appUrls);
1265
+ setMfPreviewApp((prev) =>
1266
+ status.appUrls?.[prev]
1267
+ ? prev
1268
+ : (Object.keys(status.appUrls ?? {})[0] ?? "host"),
1269
+ );
1270
+ }
1271
+ if (status.logs) {
1272
+ setSandboxOutput(
1273
+ status.logs.flatMap((chunk) =>
1274
+ chunk
1275
+ .split("\n")
1276
+ .filter(Boolean)
1277
+ .map((text) => ({
1278
+ kind: "stdout" as const,
1279
+ text,
1280
+ source: "server" as const,
1281
+ })),
1282
+ ),
1283
+ );
1284
+ }
1285
+ } catch {
1286
+ /* ignore transient network errors */
1287
+ }
1288
+ }, 1000);
1289
+ return () => clearInterval(interval);
1290
+ }, [mfSandboxId]);
1291
+
1292
+ useEffect(() => {
1293
+ if (clientType !== "module-federation" || !mfSandboxId) return;
1294
+ if (!reactActiveFile) return;
1295
+ if (Object.prototype.hasOwnProperty.call(reactFiles, reactActiveFile)) {
1296
+ return;
1297
+ }
1298
+ if (!mfGeneratedFiles.includes(reactActiveFile)) return;
1299
+ if (mfGeneratedFileContents[reactActiveFile] !== undefined) return;
1300
+ if (mfGeneratedFileRequestRef.current === reactActiveFile) return;
1301
+
1302
+ const filePath = reactActiveFile;
1303
+ let cancelled = false;
1304
+ mfGeneratedFileRequestRef.current = filePath;
1305
+ setMfLoadingFile(filePath);
1306
+
1307
+ void fetchModuleFederationGeneratedFile(mfSandboxId, filePath)
1308
+ .then(({ content }) => {
1309
+ if (cancelled) return;
1310
+ setMfGeneratedFileContents((prev) => ({
1311
+ ...prev,
1312
+ [filePath]: content,
1313
+ }));
1314
+ })
1315
+ .catch((error: any) => {
1316
+ if (cancelled) return;
1317
+ const message = error?.message ?? String(error);
1318
+ setMfError(message);
1319
+ setMfGeneratedFileContents((prev) => ({
1320
+ ...prev,
1321
+ [filePath]: `Failed to load generated file.\n\n${message}`,
1322
+ }));
1323
+ })
1324
+ .finally(() => {
1325
+ if (mfGeneratedFileRequestRef.current === filePath) {
1326
+ mfGeneratedFileRequestRef.current = null;
1327
+ }
1328
+ if (cancelled) return;
1329
+ setMfLoadingFile((current) => (current === filePath ? null : current));
1330
+ });
1331
+
1332
+ return () => {
1333
+ cancelled = true;
1334
+ };
1335
+ }, [
1336
+ clientType,
1337
+ mfGeneratedFileContents,
1338
+ mfGeneratedFiles,
1339
+ mfSandboxId,
1340
+ reactActiveFile,
1341
+ reactFiles,
1342
+ ]);
1343
+
1344
+ const runModuleFederationCommand = useCallback(async () => {
1345
+ if (!mfSandboxId || mfConsoleRunning) return;
1346
+ const command = mfConsoleCommand.trim();
1347
+ if (!command) return;
1348
+
1349
+ setMfError(null);
1350
+ setMfConsoleRunning(true);
1351
+ setSbxBottomTab("console");
1352
+
1353
+ let streamError: string | null = null;
1354
+
1355
+ try {
1356
+ await streamModuleFederationCommand(
1357
+ {
1358
+ id: mfSandboxId,
1359
+ command,
1360
+ cwd: mfConsoleCwd === "." ? undefined : mfConsoleCwd,
1361
+ },
1362
+ (message) => {
1363
+ if (message.type === "output") {
1364
+ setMfConsoleOutput((prev) => [
1365
+ ...prev,
1366
+ {
1367
+ kind: message.kind,
1368
+ text: message.text,
1369
+ source: "server",
1370
+ },
1371
+ ]);
1372
+ return;
1373
+ }
1374
+
1375
+ if (message.type === "error") {
1376
+ streamError = message.error;
1377
+ setMfConsoleOutput((prev) => [
1378
+ ...prev,
1379
+ {
1380
+ kind: "stderr",
1381
+ text: message.error,
1382
+ source: "server",
1383
+ },
1384
+ ]);
1385
+ }
1386
+ },
1387
+ );
1388
+
1389
+ if (streamError) {
1390
+ throw new Error(streamError);
1391
+ }
1392
+
1393
+ const files = await refreshModuleFederationGeneratedFiles(mfSandboxId);
1394
+ setMfConsoleOutput((prev) => [
1395
+ ...prev,
1396
+ {
1397
+ kind: "info",
1398
+ text:
1399
+ files.length > 0
1400
+ ? `Refreshed ${files.length} generated file${files.length === 1 ? "" : "s"}.`
1401
+ : "No generated dist files were found.",
1402
+ source: "server",
1403
+ },
1404
+ ]);
1405
+ } catch (err: any) {
1406
+ const message = err?.message ?? String(err);
1407
+ setMfError(message);
1408
+ if (streamError !== message) {
1409
+ setMfConsoleOutput((prev) => [
1410
+ ...prev,
1411
+ {
1412
+ kind: "stderr",
1413
+ text: message,
1414
+ source: "server",
1415
+ },
1416
+ ]);
1417
+ }
1418
+ } finally {
1419
+ setMfConsoleRunning(false);
1420
+ }
1421
+ }, [
1422
+ mfConsoleCommand,
1423
+ mfConsoleCwd,
1424
+ mfConsoleRunning,
1425
+ mfSandboxId,
1426
+ refreshModuleFederationGeneratedFiles,
1427
+ ]);
1428
+
1003
1429
  // Clean up Next.js server when the modal is closed or mode changes away from nextjs
1004
1430
  const prevClientTypeRef = useRef(clientType);
1005
1431
  useEffect(() => {
@@ -1010,21 +1436,49 @@ export default function CodeRunnerModal() {
1010
1436
  setNxSandboxId(null);
1011
1437
  setNxSandboxUrl(null);
1012
1438
  }
1013
- }, [clientType, nxSandboxId]);
1439
+ if (
1440
+ prev === "module-federation" &&
1441
+ clientType !== "module-federation" &&
1442
+ mfSandboxId
1443
+ ) {
1444
+ void stopModuleFederationSandbox(mfSandboxId);
1445
+ setMfSandboxId(null);
1446
+ setMfHostUrl(null);
1447
+ setMfAppUrls({});
1448
+ setMfGeneratedFiles([]);
1449
+ setMfGeneratedFileContents({});
1450
+ setMfLoadingFile(null);
1451
+ setMfConsoleRunning(false);
1452
+ setSbxBottomTab((current) =>
1453
+ current === "console" ? "output" : current,
1454
+ );
1455
+ }
1456
+ }, [clientType, nxSandboxId, mfSandboxId]);
1014
1457
 
1015
1458
  // Clean up on unmount
1016
1459
  useEffect(() => {
1017
1460
  return () => {
1018
1461
  if (nxSandboxId) void stopNextjsSandbox(nxSandboxId);
1462
+ if (mfSandboxId) void stopModuleFederationSandbox(mfSandboxId);
1019
1463
  };
1020
1464
  // eslint-disable-next-line react-hooks/exhaustive-deps
1021
- }, [nxSandboxId]);
1465
+ }, [nxSandboxId, mfSandboxId]);
1022
1466
 
1023
1467
  const handleClientTypeChange = useCallback(
1024
- (ct: "script" | "react" | "nextjs") => {
1468
+ (ct: FrontendClientType) => {
1025
1469
  if (ct === clientType) return;
1026
1470
  setClientType(ct);
1027
- if (ct === "react" || ct === "nextjs") {
1471
+ if (ct !== "module-federation") {
1472
+ setMfGeneratedFiles([]);
1473
+ setMfGeneratedFileContents({});
1474
+ setMfLoadingFile(null);
1475
+ setMfConsoleOutput([]);
1476
+ setMfConsoleRunning(false);
1477
+ setSbxBottomTab((current) =>
1478
+ current === "console" ? "output" : current,
1479
+ );
1480
+ }
1481
+ if (ct !== "script") {
1028
1482
  const defs = defaultForType(ct);
1029
1483
  setReactFiles(defs.files);
1030
1484
  setReactActiveFile(defs.activeFile);
@@ -1036,6 +1490,16 @@ export default function CodeRunnerModal() {
1036
1490
  setReactNavHistory(["/"]);
1037
1491
  setReactNavIndex(0);
1038
1492
  }
1493
+ if (ct === "module-federation") {
1494
+ setServerCollapsed(true);
1495
+ setClientCollapsed(false);
1496
+ setMfPreviewApp("host");
1497
+ setMfError(null);
1498
+ }
1499
+ if (ct === "module-federation") {
1500
+ setMfConsoleCommand("npm run build");
1501
+ setMfConsoleCwd("apps/host");
1502
+ }
1039
1503
  }
1040
1504
  },
1041
1505
  [clientType],
@@ -1061,12 +1525,19 @@ export default function CodeRunnerModal() {
1061
1525
  ...prev,
1062
1526
  { id: aId, role: "assistant", content: "" },
1063
1527
  ]);
1064
- const isReactMode = clientType === "react" || clientType === "nextjs";
1065
- const workspaceFiles = isReactMode
1528
+ const isFrontendMode =
1529
+ clientType === "react" ||
1530
+ clientType === "nextjs" ||
1531
+ clientType === "module-federation";
1532
+ const workspaceFiles = isFrontendMode
1066
1533
  ? reactFiles
1067
1534
  : { "client.js": clientCode, "server.ts": serverCode };
1068
- const labType: "react" | "nextjs" =
1069
- clientType === "nextjs" ? "nextjs" : "react";
1535
+ const labType: FrontendLabType =
1536
+ clientType === "nextjs"
1537
+ ? "nextjs"
1538
+ : clientType === "module-federation"
1539
+ ? "module-federation"
1540
+ : "react";
1070
1541
  try {
1071
1542
  const history = [...sbxChatMessages, userMsg].map((m) => ({
1072
1543
  role: m.role,
@@ -1278,6 +1749,27 @@ export default function CodeRunnerModal() {
1278
1749
  minHeight: MIN_H,
1279
1750
  };
1280
1751
 
1752
+ const moduleFederationCommandRoots =
1753
+ getModuleFederationCommandRoots(reactFiles);
1754
+ const moduleFederationGeneratedFiles = mfGeneratedFiles.filter(
1755
+ (filePath) => !Object.prototype.hasOwnProperty.call(reactFiles, filePath),
1756
+ );
1757
+ const moduleFederationGeneratedFileSet = new Set(
1758
+ moduleFederationGeneratedFiles,
1759
+ );
1760
+ const visibleReactFiles =
1761
+ clientType === "module-federation"
1762
+ ? Array.from(
1763
+ new Set([
1764
+ ...Object.keys(reactFiles),
1765
+ ...moduleFederationGeneratedFiles,
1766
+ ]),
1767
+ )
1768
+ : Object.keys(reactFiles);
1769
+ const isActiveModuleFederationGeneratedFile =
1770
+ clientType === "module-federation" &&
1771
+ moduleFederationGeneratedFileSet.has(reactActiveFile);
1772
+
1281
1773
  return (
1282
1774
  <div
1283
1775
  className="fixed z-[60] flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
@@ -1944,7 +2436,14 @@ export default function CodeRunnerModal() {
1944
2436
  </span>
1945
2437
  {/* Client type selector: JS / React / Next */}
1946
2438
  <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) => (
2439
+ {(
2440
+ [
2441
+ "script",
2442
+ "react",
2443
+ "nextjs",
2444
+ "module-federation",
2445
+ ] as const
2446
+ ).map((ct) => (
1948
2447
  <button
1949
2448
  key={ct}
1950
2449
  type="button"
@@ -1959,7 +2458,9 @@ export default function CodeRunnerModal() {
1959
2458
  ? "JS"
1960
2459
  : ct === "react"
1961
2460
  ? "React"
1962
- : "Next"}
2461
+ : ct === "nextjs"
2462
+ ? "Next"
2463
+ : "Webpack"}
1963
2464
  </button>
1964
2465
  ))}
1965
2466
  </div>
@@ -2023,9 +2524,11 @@ export default function CodeRunnerModal() {
2023
2524
  </>
2024
2525
  )}
2025
2526
  {/* React/Next mode: optional URL + Preview button + edit/preview toggle for Next */}
2026
- {(clientType === "react" || clientType === "nextjs") && (
2527
+ {(clientType === "react" ||
2528
+ clientType === "nextjs" ||
2529
+ clientType === "module-federation") && (
2027
2530
  <>
2028
- {sandboxUrl && (
2531
+ {clientType !== "module-federation" && sandboxUrl && (
2029
2532
  <span
2030
2533
  className="text-[9px] font-mono text-slate-600 truncate max-w-[80px]"
2031
2534
  title={sandboxUrl}
@@ -2037,7 +2540,7 @@ export default function CodeRunnerModal() {
2037
2540
  {clientType === "react" && (
2038
2541
  <button
2039
2542
  type="button"
2040
- onClick={refreshPreview}
2543
+ onClick={() => refreshPreview()}
2041
2544
  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
2545
  title="Render preview"
2043
2546
  >
@@ -2093,6 +2596,73 @@ export default function CodeRunnerModal() {
2093
2596
  )}
2094
2597
  </>
2095
2598
  )}
2599
+ {clientType === "module-federation" && (
2600
+ <>
2601
+ {mfSandboxId && mfHostUrl && (
2602
+ <span
2603
+ className="text-[9px] font-mono text-slate-600 truncate max-w-[110px]"
2604
+ title={mfHostUrl}
2605
+ >
2606
+ {mfHostUrl.replace(/^https?:\/\//, "")}
2607
+ </span>
2608
+ )}
2609
+ {!mfSandboxId ? (
2610
+ <button
2611
+ type="button"
2612
+ onClick={() => void startModuleFederationServer()}
2613
+ disabled={mfStarting}
2614
+ 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"
2615
+ title="Start real webpack module federation dev servers"
2616
+ >
2617
+ {mfStarting ? (
2618
+ <Loader2 className="w-3 h-3 animate-spin" />
2619
+ ) : (
2620
+ <Play className="w-3 h-3" />
2621
+ )}
2622
+ {mfStarting ? "Starting…" : "Run Webpack"}
2623
+ </button>
2624
+ ) : (
2625
+ <>
2626
+ <div className="flex items-center rounded overflow-hidden border border-slate-700/50 text-[9px] shrink-0">
2627
+ <button
2628
+ type="button"
2629
+ onClick={() => setReactClientTab("edit")}
2630
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2631
+ reactClientTab === "edit"
2632
+ ? "bg-slate-700 text-slate-200"
2633
+ : "text-slate-500 hover:text-slate-400"
2634
+ }`}
2635
+ title="Edit code"
2636
+ >
2637
+ <Code2 className="w-2.5 h-2.5" />
2638
+ </button>
2639
+ <button
2640
+ type="button"
2641
+ onClick={() => setReactClientTab("preview")}
2642
+ className={`flex items-center gap-0.5 px-1.5 py-0.5 transition-colors ${
2643
+ reactClientTab === "preview"
2644
+ ? "bg-slate-700 text-slate-200"
2645
+ : "text-slate-500 hover:text-slate-400"
2646
+ }`}
2647
+ title="Live preview"
2648
+ >
2649
+ <Eye className="w-2.5 h-2.5" />
2650
+ </button>
2651
+ </div>
2652
+ <button
2653
+ type="button"
2654
+ onClick={() =>
2655
+ void stopModuleFederationServer()
2656
+ }
2657
+ className="p-0.5 rounded text-slate-600 hover:text-red-400 transition-colors shrink-0"
2658
+ title="Stop webpack lab"
2659
+ >
2660
+ <StopCircle className="w-3 h-3" />
2661
+ </button>
2662
+ </>
2663
+ )}
2664
+ </>
2665
+ )}
2096
2666
  </>
2097
2667
  )}
2098
2668
  </div>
@@ -2215,10 +2785,11 @@ export default function CodeRunnerModal() {
2215
2785
 
2216
2786
  {/* Client body */}
2217
2787
  <div
2218
- className={`flex-1 min-h-0 ${clientType === "nextjs" ? "flex flex-row" : "relative"}`}
2788
+ className={`flex-1 min-h-0 ${clientType === "nextjs" || clientType === "module-federation" ? "flex flex-row" : "relative"}`}
2219
2789
  >
2220
2790
  {/* ── Next.js VS Code-style file tree sidebar ── */}
2221
- {clientType === "nextjs" && (
2791
+ {(clientType === "nextjs" ||
2792
+ clientType === "module-federation") && (
2222
2793
  <div className="w-36 shrink-0 flex flex-col border-r border-slate-700 bg-slate-900/60 overflow-y-auto">
2223
2794
  {/* Sidebar header */}
2224
2795
  <div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-700/60">
@@ -2256,7 +2827,11 @@ export default function CodeRunnerModal() {
2256
2827
  setReactNewFileName("");
2257
2828
  }
2258
2829
  }}
2259
- placeholder="app/new.tsx"
2830
+ placeholder={
2831
+ clientType === "module-federation"
2832
+ ? "apps/orders/src/App.jsx"
2833
+ : "app/new.tsx"
2834
+ }
2260
2835
  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
2836
  />
2262
2837
  ) : (
@@ -2264,7 +2839,11 @@ export default function CodeRunnerModal() {
2264
2839
  type="button"
2265
2840
  onClick={() => setReactAddingFile(true)}
2266
2841
  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)"
2842
+ title={
2843
+ clientType === "module-federation"
2844
+ ? "New file (use paths like apps/orders/src/App.jsx)"
2845
+ : "New file (use paths like app/dashboard/page.tsx)"
2846
+ }
2268
2847
  >
2269
2848
  <FilePlus className="w-3 h-3" />
2270
2849
  </button>
@@ -2273,27 +2852,18 @@ export default function CodeRunnerModal() {
2273
2852
  {/* Tree nodes */}
2274
2853
  <div className="flex-1 py-1">
2275
2854
  {(() => {
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
- );
2855
+ const tree = buildFileTree(visibleReactFiles);
2295
2856
 
2296
- const fileIcon = (name: string) => {
2857
+ const fileIcon = (
2858
+ name: string,
2859
+ isGenerated = false,
2860
+ ) => {
2861
+ if (isGenerated)
2862
+ return (
2863
+ <span className="text-emerald-400 mr-1 text-[8px] font-semibold uppercase">
2864
+ dist
2865
+ </span>
2866
+ );
2297
2867
  if (name.endsWith(".tsx") || name.endsWith(".jsx"))
2298
2868
  return (
2299
2869
  <span className="text-cyan-400 mr-1 text-[9px]">
@@ -2315,84 +2885,117 @@ export default function CodeRunnerModal() {
2315
2885
 
2316
2886
  const renderFile = (path: string, indent = 0) => (
2317
2887
  <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>
2888
+ {(() => {
2889
+ const isGenerated =
2890
+ moduleFederationGeneratedFileSet.has(path);
2891
+ return (
2892
+ <>
2893
+ <button
2894
+ type="button"
2895
+ onClick={() => {
2896
+ setReactActiveFile(path);
2897
+ setReactClientTab("edit");
2898
+ }}
2899
+ style={{
2900
+ paddingLeft: `${8 + indent * 10}px`,
2901
+ }}
2902
+ className={`flex-1 flex items-center gap-0.5 py-0.5 pr-1 text-left text-[10px] font-mono truncate transition-colors ${
2903
+ path === reactActiveFile &&
2904
+ reactClientTab === "edit"
2905
+ ? isGenerated
2906
+ ? "bg-slate-700 text-emerald-100"
2907
+ : "bg-slate-700 text-slate-100"
2908
+ : isGenerated
2909
+ ? "text-emerald-300/80 hover:bg-slate-800 hover:text-emerald-100"
2910
+ : "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
2911
+ }`}
2912
+ title={
2913
+ isGenerated
2914
+ ? `${path} (generated)`
2915
+ : path
2916
+ }
2917
+ >
2918
+ {fileIcon(
2919
+ path.split("/").pop() ?? path,
2920
+ isGenerated,
2921
+ )}
2922
+ <span className="truncate">
2923
+ {path.split("/").pop()}
2924
+ </span>
2925
+ </button>
2926
+ {!isGenerated && (
2927
+ <button
2928
+ type="button"
2929
+ onClick={() => {
2930
+ if (
2931
+ Object.keys(reactFiles).length <= 1
2932
+ )
2933
+ return;
2934
+ const remaining = Object.keys(
2935
+ reactFiles,
2936
+ ).filter((f) => f !== path);
2937
+ setReactFiles((prev) => {
2938
+ const next = { ...prev };
2939
+ delete next[path];
2940
+ return next;
2941
+ });
2942
+ if (reactActiveFile === path)
2943
+ setReactActiveFile(
2944
+ remaining[0] ?? "",
2945
+ );
2946
+ }}
2947
+ className="opacity-0 group-hover:opacity-100 p-0.5 mr-1 rounded text-slate-600 hover:text-red-400 transition-all shrink-0"
2948
+ title="Delete file"
2949
+ >
2950
+ <X className="w-2.5 h-2.5" />
2951
+ </button>
2952
+ )}
2953
+ </>
2954
+ );
2955
+ })()}
2359
2956
  </div>
2360
2957
  );
2361
2958
 
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
- );
2959
+ const renderNode = (
2960
+ node: FileTreeNode,
2961
+ indent = 0,
2962
+ ): React.ReactNode => {
2963
+ if (!node.path) {
2964
+ return (
2965
+ <>
2966
+ {node.children
2967
+ .sort((a, b) =>
2968
+ a.name.localeCompare(b.name),
2969
+ )
2970
+ .map((child) => renderNode(child, 0))}
2971
+ {node.files
2972
+ .sort((a, b) => a.localeCompare(b))
2973
+ .map((path) => renderFile(path, 0))}
2974
+ </>
2975
+ );
2976
+ }
2977
+
2978
+ const isOpen = !collapsedFolders.has(node.path);
2381
2979
 
2382
2980
  return (
2383
- <div key={folder}>
2384
- {/* Folder row */}
2981
+ <div key={node.path}>
2385
2982
  <button
2386
2983
  type="button"
2387
2984
  onClick={() =>
2388
2985
  setCollapsedFolders((prev) => {
2389
2986
  const next = new Set(prev);
2390
- if (next.has(folder)) next.delete(folder);
2391
- else next.add(folder);
2987
+ if (next.has(node.path)) {
2988
+ next.delete(node.path);
2989
+ } else {
2990
+ next.add(node.path);
2991
+ }
2392
2992
  return next;
2393
2993
  })
2394
2994
  }
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"
2995
+ style={{
2996
+ paddingLeft: `${8 + indent * 10}px`,
2997
+ }}
2998
+ 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"
2396
2999
  >
2397
3000
  {isOpen ? (
2398
3001
  <ChevronDown className="w-2.5 h-2.5 shrink-0 text-slate-500" />
@@ -2402,68 +3005,29 @@ export default function CodeRunnerModal() {
2402
3005
  <span className="text-yellow-300/80 mr-0.5">
2403
3006
  📁
2404
3007
  </span>
2405
- <span className="truncate">{folder}/</span>
3008
+ <span className="truncate">{node.name}/</span>
2406
3009
  </button>
2407
- {/* Children */}
2408
3010
  {isOpen && (
2409
3011
  <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))}
3012
+ {node.children
3013
+ .sort((a, b) =>
3014
+ a.name.localeCompare(b.name),
3015
+ )
3016
+ .map((child) =>
3017
+ renderNode(child, indent + 1),
3018
+ )}
3019
+ {node.files
3020
+ .sort((a, b) => a.localeCompare(b))
3021
+ .map((path) =>
3022
+ renderFile(path, indent + 1),
3023
+ )}
2455
3024
  </div>
2456
3025
  )}
2457
3026
  </div>
2458
3027
  );
2459
3028
  };
2460
3029
 
2461
- return (
2462
- <>
2463
- {folders.map(renderFolder)}
2464
- {rootFiles.map((f) => renderFile(f, 0))}
2465
- </>
2466
- );
3030
+ return renderNode(tree);
2467
3031
  })()}
2468
3032
  </div>
2469
3033
  </div>
@@ -2471,7 +3035,7 @@ export default function CodeRunnerModal() {
2471
3035
 
2472
3036
  {/* ── Editor / Preview area ── */}
2473
3037
  <div
2474
- className={`${clientType === "nextjs" ? "flex-1 min-w-0 relative" : "absolute inset-0"}`}
3038
+ className={`${clientType === "nextjs" || clientType === "module-federation" ? "flex-1 min-w-0 relative" : "absolute inset-0"}`}
2475
3039
  >
2476
3040
  {clientType === "script" ? (
2477
3041
  <SyntaxEditor
@@ -2488,31 +3052,52 @@ export default function CodeRunnerModal() {
2488
3052
  }
2489
3053
  />
2490
3054
  ) : 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
- />
3055
+ isActiveModuleFederationGeneratedFile ? (
3056
+ <div className="absolute inset-0 flex flex-col bg-slate-950">
3057
+ <div className="shrink-0 border-b border-slate-800 bg-slate-900/80 px-3 py-1.5 text-[10px] font-mono text-emerald-300">
3058
+ Read-only build artifact from dist/. Run the webpack
3059
+ console again to refresh it.
3060
+ </div>
3061
+ <div className="flex-1 overflow-auto px-3 py-3 font-mono text-[12px] leading-relaxed text-slate-200">
3062
+ {mfLoadingFile === reactActiveFile &&
3063
+ mfGeneratedFileContents[reactActiveFile] ===
3064
+ undefined ? (
3065
+ <div className="flex h-full items-center justify-center gap-2 text-slate-500">
3066
+ <Loader2 className="w-4 h-4 animate-spin" />
3067
+ <span>Loading generated file…</span>
3068
+ </div>
3069
+ ) : (
3070
+ <pre className="m-0 min-w-max whitespace-pre">
3071
+ {mfGeneratedFileContents[reactActiveFile] ?? ""}
3072
+ </pre>
3073
+ )}
3074
+ </div>
3075
+ </div>
3076
+ ) : (
3077
+ <SyntaxEditor
3078
+ key={reactActiveFile}
3079
+ value={reactFiles[reactActiveFile] ?? ""}
3080
+ onChange={(val) =>
3081
+ setReactFiles((prev) => ({
3082
+ ...prev,
3083
+ [reactActiveFile]: val,
3084
+ }))
3085
+ }
3086
+ language={
3087
+ reactActiveFile.endsWith(".ts") ||
3088
+ reactActiveFile.endsWith(".tsx")
3089
+ ? "typescript"
3090
+ : "javascript"
3091
+ }
3092
+ fontSize="12px"
3093
+ focusRingClass="ring-cyan-500/30"
3094
+ placeholder={`// ${reactActiveFile}\n`}
3095
+ />
3096
+ )
2510
3097
  ) : (
2511
- // Preview area — URL bar for Next.js, plain iframe for React
2512
3098
  <div className="w-full h-full flex flex-col">
2513
3099
  {clientType === "nextjs" && (
2514
3100
  <div className="flex items-center gap-1 px-2 py-1 bg-slate-800 border-b border-slate-700 shrink-0">
2515
- {/* Back */}
2516
3101
  <button
2517
3102
  type="button"
2518
3103
  disabled={reactNavIndex <= 0}
@@ -2535,7 +3120,6 @@ export default function CodeRunnerModal() {
2535
3120
  >
2536
3121
  <ChevronLeft className="w-3.5 h-3.5" />
2537
3122
  </button>
2538
- {/* Forward */}
2539
3123
  <button
2540
3124
  type="button"
2541
3125
  disabled={
@@ -2560,7 +3144,6 @@ export default function CodeRunnerModal() {
2560
3144
  >
2561
3145
  <ChevronRight className="w-3.5 h-3.5" />
2562
3146
  </button>
2563
- {/* Refresh */}
2564
3147
  <button
2565
3148
  type="button"
2566
3149
  onClick={() => {
@@ -2582,7 +3165,6 @@ export default function CodeRunnerModal() {
2582
3165
  <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
3166
  </svg>
2584
3167
  </button>
2585
- {/* URL bar */}
2586
3168
  <form
2587
3169
  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
3170
  onSubmit={(e) => {
@@ -2621,7 +3203,6 @@ export default function CodeRunnerModal() {
2621
3203
  spellCheck={false}
2622
3204
  />
2623
3205
  </form>
2624
- {/* Status indicator */}
2625
3206
  {nxSandboxUrl ? (
2626
3207
  <span className="text-[9px] font-mono text-green-400 shrink-0">
2627
3208
  ● live
@@ -2648,49 +3229,103 @@ export default function CodeRunnerModal() {
2648
3229
  )}
2649
3230
  </div>
2650
3231
  )}
2651
- {/* Error banner */}
2652
- {nxError && (
3232
+ {clientType === "module-federation" && (
3233
+ <div className="flex items-center gap-1 px-2 py-1 bg-slate-800 border-b border-slate-700 shrink-0 overflow-x-auto">
3234
+ {Object.entries(mfAppUrls).map(([name, url]) => (
3235
+ <button
3236
+ key={name}
3237
+ type="button"
3238
+ onClick={() => setMfPreviewApp(name)}
3239
+ className={`px-2 py-0.5 rounded text-[10px] font-mono transition-colors shrink-0 ${
3240
+ mfPreviewApp === name
3241
+ ? "bg-slate-700 text-slate-100"
3242
+ : "text-slate-500 hover:text-slate-300 hover:bg-slate-700/40"
3243
+ }`}
3244
+ title={url}
3245
+ >
3246
+ {name}
3247
+ </button>
3248
+ ))}
3249
+ <div className="ml-auto text-[9px] font-mono text-slate-600 shrink-0">
3250
+ {mfAppUrls[mfPreviewApp] ??
3251
+ mfHostUrl ??
3252
+ "Start webpack to preview"}
3253
+ </div>
3254
+ </div>
3255
+ )}
3256
+ {((clientType === "module-federation" && mfError) ||
3257
+ (clientType !== "module-federation" && nxError)) && (
2653
3258
  <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}
3259
+ {clientType === "module-federation"
3260
+ ? mfError
3261
+ : nxError}
2655
3262
  </div>
2656
3263
  )}
2657
- {/* Starting overlay */}
2658
- {nxStarting && (
3264
+ {(nxStarting || mfStarting) && (
2659
3265
  <div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-400 text-sm bg-slate-950">
2660
3266
  <Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
2661
3267
  <p className="text-[12px]">
2662
- Starting Next.js dev server…
3268
+ {clientType === "module-federation"
3269
+ ? "Installing dependencies and starting webpack apps…"
3270
+ : "Starting Next.js dev server…"}
2663
3271
  </p>
2664
- <p className="text-[10px] text-slate-600">
2665
- This takes ~10 seconds on the first run
3272
+ <p className="text-[10px] text-slate-600 max-w-md text-center px-4">
3273
+ {clientType === "module-federation"
3274
+ ? "The first run can take a little longer because npm install runs inside the lab sandbox."
3275
+ : "This takes ~10 seconds on the first run"}
2666
3276
  </p>
2667
3277
  </div>
2668
3278
  )}
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);
3279
+ {!nxStarting &&
3280
+ clientType === "nextjs" &&
3281
+ nxSandboxUrl && (
3282
+ <iframe
3283
+ ref={nxIframeRef}
3284
+ src={nxSandboxUrl + reactPreviewPath}
3285
+ className="flex-1 min-h-0 w-full border-0 bg-white"
3286
+ title="Next.js Preview"
3287
+ onLoad={() => {
3288
+ try {
3289
+ const p =
3290
+ nxIframeRef.current?.contentWindow?.location
3291
+ .pathname;
3292
+ if (p) {
3293
+ setReactPreviewPath(p);
3294
+ setReactNavInput(p);
3295
+ }
3296
+ } catch {
3297
+ // cross-origin — ignore
2685
3298
  }
2686
- } catch {
2687
- // cross-origin — ignore
2688
- }
2689
- }}
2690
- />
2691
- )}
2692
- {/* Simulation iframe (when no real server) */}
2693
- {!nxStarting && !nxSandboxUrl && (
3299
+ }}
3300
+ />
3301
+ )}
3302
+ {!mfStarting &&
3303
+ clientType === "module-federation" &&
3304
+ mfSandboxId &&
3305
+ (mfAppUrls[mfPreviewApp] ?? mfHostUrl) && (
3306
+ <iframe
3307
+ src={mfAppUrls[mfPreviewApp] ?? mfHostUrl ?? ""}
3308
+ className="flex-1 min-h-0 w-full border-0 bg-white"
3309
+ title="Webpack Module Federation Preview"
3310
+ />
3311
+ )}
3312
+ {!mfStarting &&
3313
+ clientType === "module-federation" &&
3314
+ !mfSandboxId && (
3315
+ <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">
3316
+ <Server className="w-8 h-8 text-cyan-400/70" />
3317
+ <p className="text-[12px]">
3318
+ Start the webpack lab to launch the host and
3319
+ remote dev servers.
3320
+ </p>
3321
+ <p className="text-[10px] text-slate-600 max-w-md">
3322
+ The preview lets you switch between the host and
3323
+ each remote so you can inspect their output
3324
+ independently.
3325
+ </p>
3326
+ </div>
3327
+ )}
3328
+ {!nxStarting && clientType === "react" && (
2694
3329
  <iframe
2695
3330
  srcDoc={reactPreviewSrc ?? ""}
2696
3331
  sandbox="allow-scripts"
@@ -2741,6 +3376,20 @@ export default function CodeRunnerModal() {
2741
3376
  ) : null}
2742
3377
  Output
2743
3378
  </button>
3379
+ {clientType === "module-federation" && (
3380
+ <button
3381
+ type="button"
3382
+ onClick={() => setSbxBottomTab("console")}
3383
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider font-medium border-b-2 transition-colors ${
3384
+ sbxBottomTab === "console"
3385
+ ? "border-cyan-500 text-cyan-300"
3386
+ : "border-transparent text-slate-500 hover:text-slate-300"
3387
+ }`}
3388
+ >
3389
+ <Terminal className="w-3 h-3" />
3390
+ Console
3391
+ </button>
3392
+ )}
2744
3393
  <button
2745
3394
  type="button"
2746
3395
  onClick={() => {
@@ -2761,6 +3410,9 @@ export default function CodeRunnerModal() {
2761
3410
  (serverStarting || clientRunning) && (
2762
3411
  <Loader2 className="w-3 h-3 text-emerald-400 animate-spin mr-1" />
2763
3412
  )}
3413
+ {sbxBottomTab === "console" && mfConsoleRunning && (
3414
+ <Loader2 className="w-3 h-3 text-cyan-400 animate-spin mr-1" />
3415
+ )}
2764
3416
  {sbxBottomTab === "output" && sandboxOutput.length > 0 && (
2765
3417
  <div className="flex items-center gap-1 mr-1">
2766
3418
  <button
@@ -2785,6 +3437,30 @@ export default function CodeRunnerModal() {
2785
3437
  </button>
2786
3438
  </div>
2787
3439
  )}
3440
+ {sbxBottomTab === "console" && mfConsoleOutput.length > 0 && (
3441
+ <div className="flex items-center gap-1 mr-1">
3442
+ <button
3443
+ type="button"
3444
+ onClick={() =>
3445
+ navigator.clipboard.writeText(
3446
+ mfConsoleOutput.map((line) => line.text).join("\n"),
3447
+ )
3448
+ }
3449
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
3450
+ title="Copy console output"
3451
+ >
3452
+ <Copy className="w-3 h-3" />
3453
+ </button>
3454
+ <button
3455
+ type="button"
3456
+ onClick={() => setMfConsoleOutput([])}
3457
+ className="p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
3458
+ title="Clear console output"
3459
+ >
3460
+ <Trash2 className="w-3 h-3" />
3461
+ </button>
3462
+ </div>
3463
+ )}
2788
3464
  {sbxBottomTab === "chat" && sbxChatMessages.length > 0 && (
2789
3465
  <button
2790
3466
  type="button"
@@ -2815,7 +3491,9 @@ export default function CodeRunnerModal() {
2815
3491
  !serverStarting &&
2816
3492
  !clientRunning && (
2817
3493
  <span className="text-slate-600">
2818
- Start the server, then run the client
3494
+ {clientType === "module-federation"
3495
+ ? "Run webpack to start the host and remotes"
3496
+ : "Start the server, then run the client"}
2819
3497
  </span>
2820
3498
  )}
2821
3499
  {sandboxOutput.map((line, i) => (
@@ -2854,6 +3532,98 @@ export default function CodeRunnerModal() {
2854
3532
  </div>
2855
3533
  )}
2856
3534
 
3535
+ {sbxBottomTab === "console" && (
3536
+ <div className="flex-1 min-h-0 flex flex-col">
3537
+ <div className="shrink-0 border-b border-slate-800 bg-slate-900/70 px-3 py-2 flex items-center gap-2">
3538
+ <select
3539
+ value={mfConsoleCwd}
3540
+ onChange={(e) => setMfConsoleCwd(e.target.value)}
3541
+ disabled={!mfSandboxId || mfConsoleRunning}
3542
+ className="bg-slate-950 border border-slate-700 rounded px-2 py-1 text-[11px] font-mono text-slate-200 outline-none disabled:opacity-50"
3543
+ >
3544
+ {moduleFederationCommandRoots.map((root) => (
3545
+ <option key={root} value={root}>
3546
+ {root === "." ? "root" : root}
3547
+ </option>
3548
+ ))}
3549
+ </select>
3550
+ <input
3551
+ value={mfConsoleCommand}
3552
+ onChange={(e) => setMfConsoleCommand(e.target.value)}
3553
+ onKeyDown={(e) => {
3554
+ if (e.key === "Enter") {
3555
+ e.preventDefault();
3556
+ void runModuleFederationCommand();
3557
+ }
3558
+ }}
3559
+ disabled={!mfSandboxId || mfConsoleRunning}
3560
+ placeholder="npm run build"
3561
+ className="flex-1 bg-slate-950 border border-slate-700 rounded px-2 py-1 text-[11px] font-mono text-slate-200 placeholder-slate-600 outline-none disabled:opacity-50"
3562
+ spellCheck={false}
3563
+ />
3564
+ <button
3565
+ type="button"
3566
+ onClick={() => void runModuleFederationCommand()}
3567
+ disabled={
3568
+ !mfSandboxId ||
3569
+ mfConsoleRunning ||
3570
+ !mfConsoleCommand.trim()
3571
+ }
3572
+ className="flex items-center gap-1 px-2.5 py-1 rounded text-[11px] font-medium bg-cyan-600/20 hover:bg-cyan-600/35 text-cyan-300 disabled:opacity-50 transition-colors shrink-0"
3573
+ >
3574
+ {mfConsoleRunning ? (
3575
+ <Loader2 className="w-3 h-3 animate-spin" />
3576
+ ) : (
3577
+ <Play className="w-3 h-3" />
3578
+ )}
3579
+ Run
3580
+ </button>
3581
+ <button
3582
+ type="button"
3583
+ onClick={() => void refreshModuleFederationGeneratedFiles()}
3584
+ disabled={!mfSandboxId || mfConsoleRunning}
3585
+ className="px-2 py-1 rounded text-[10px] font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-800 disabled:opacity-50 transition-colors shrink-0"
3586
+ >
3587
+ Refresh dist
3588
+ </button>
3589
+ </div>
3590
+ <div className="shrink-0 px-3 py-1.5 text-[10px] text-slate-500 border-b border-slate-800">
3591
+ Run npm scripts in the selected webpack app. Generated dist
3592
+ files appear in the explorer as read-only artifacts.
3593
+ </div>
3594
+ <div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[12px] leading-relaxed">
3595
+ {mfConsoleOutput.length === 0 && !mfConsoleRunning && (
3596
+ <span className="text-slate-600">
3597
+ {mfSandboxId
3598
+ ? "Run npm run build in apps/host, apps/profile, or apps/checkout to inspect dist/."
3599
+ : "Start webpack first, then run npm commands here."}
3600
+ </span>
3601
+ )}
3602
+ {mfConsoleOutput.map((line, index) => (
3603
+ <div key={index} className="flex items-start gap-2">
3604
+ <span className="shrink-0 text-[9px] font-bold mt-0.5 w-7 text-right text-cyan-600">
3605
+ cmd
3606
+ </span>
3607
+ <span
3608
+ className={
3609
+ line.kind === "stderr"
3610
+ ? "text-red-400 whitespace-pre-wrap"
3611
+ : line.kind === "warn"
3612
+ ? "text-amber-400 whitespace-pre-wrap"
3613
+ : line.kind === "info"
3614
+ ? "text-slate-500 italic whitespace-pre-wrap"
3615
+ : "text-slate-200 whitespace-pre-wrap"
3616
+ }
3617
+ >
3618
+ {line.text}
3619
+ </span>
3620
+ </div>
3621
+ ))}
3622
+ <div ref={mfConsoleEndRef} />
3623
+ </div>
3624
+ </div>
3625
+ )}
3626
+
2857
3627
  {/* Chat tab */}
2858
3628
  {sbxBottomTab === "chat" && (
2859
3629
  <>
@@ -2864,9 +3634,13 @@ export default function CodeRunnerModal() {
2864
3634
  {sbxChatMessages.length === 0 && (
2865
3635
  <p className="text-xs text-slate-600 pt-1">
2866
3636
  Ask anything about your code —{" "}
2867
- {clientType === "react" || clientType === "nextjs" ? (
3637
+ {clientType === "react" ||
3638
+ clientType === "nextjs" ||
3639
+ clientType === "module-federation" ? (
2868
3640
  <span className="text-slate-500">
2869
- "Why does my useEffect run twice?"
3641
+ {clientType === "module-federation"
3642
+ ? '"Why is remoteEntry.js 404ing?"'
3643
+ : '"Why does my useEffect run twice?"'}
2870
3644
  </span>
2871
3645
  ) : (
2872
3646
  <span className="text-slate-500">
@@ -3002,7 +3776,7 @@ export default function CodeRunnerModal() {
3002
3776
  void handleSbxChatSend();
3003
3777
  }
3004
3778
  }}
3005
- placeholder={`Ask about your ${clientType === "react" ? "React" : clientType === "nextjs" ? "Next.js" : "sandbox"} code…`}
3779
+ placeholder={`Ask about your ${clientType === "react" ? "React" : clientType === "nextjs" ? "Next.js" : clientType === "module-federation" ? "webpack module federation" : "sandbox"} code…`}
3006
3780
  disabled={sbxChatLoading}
3007
3781
  className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-600 outline-none resize-none disabled:opacity-50 max-h-20"
3008
3782
  />