create-interview-cockpit 0.12.0 → 0.14.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.
@@ -0,0 +1,626 @@
1
+ import { useState } from "react";
2
+ import { useStore } from "../store";
3
+ import { parseInfraLabWorkspace } from "../infraLab";
4
+ import {
5
+ parseFrontendLabWorkspace,
6
+ ISOLATED_MODULE_FEDERATION_LAB,
7
+ } from "../reactLab";
8
+ import type { ContextFile } from "../types";
9
+ import {
10
+ Plus,
11
+ Play,
12
+ Trash2,
13
+ Server,
14
+ Globe,
15
+ Atom,
16
+ Layout,
17
+ Check,
18
+ X,
19
+ Pencil,
20
+ FlaskConical,
21
+ LinkIcon,
22
+ Link2Off,
23
+ Network,
24
+ } from "lucide-react";
25
+
26
+ // ─── Helpers ─────────────────────────────────────────────
27
+
28
+ const LAB_ORIGINS = new Set([
29
+ "sandbox",
30
+ "infra",
31
+ "react",
32
+ "nextjs",
33
+ "module-federation",
34
+ ]);
35
+
36
+ function isLabFile(cf: ContextFile) {
37
+ return LAB_ORIGINS.has(cf.origin ?? "");
38
+ }
39
+
40
+ // ─── Reusable lab item row ────────────────────────────────
41
+
42
+ interface LabItemProps {
43
+ cf: ContextFile;
44
+ questionId: string;
45
+ accentClass: string; // e.g. "text-cyan-200"
46
+ bgClass: string; // e.g. "bg-cyan-500/10 border-cyan-500/20"
47
+ renamingId: string | null;
48
+ renameValue: string;
49
+ onStartRename: (id: string, current: string) => void;
50
+ onCommitRename: () => Promise<void>;
51
+ onCancelRename: () => void;
52
+ setRenameValue: (v: string) => void;
53
+ onOpen: () => void;
54
+ onDetach: () => void;
55
+ onAttach: () => void;
56
+ onDelete: () => void;
57
+ openTitle: string;
58
+ }
59
+
60
+ function LabItem({
61
+ cf,
62
+ accentClass,
63
+ bgClass,
64
+ renamingId,
65
+ renameValue,
66
+ onStartRename,
67
+ onCommitRename,
68
+ onCancelRename,
69
+ setRenameValue,
70
+ onOpen,
71
+ onDetach,
72
+ onAttach,
73
+ onDelete,
74
+ openTitle,
75
+ }: LabItemProps) {
76
+ const detached = cf.inContext === false;
77
+
78
+ return (
79
+ <div
80
+ className={`flex items-center gap-1 text-xs border rounded px-1.5 py-1 group transition-opacity ${bgClass} ${
81
+ detached ? "opacity-50" : ""
82
+ }`}
83
+ >
84
+ {renamingId !== cf.id && (
85
+ <span
86
+ className={`${accentClass} font-medium truncate flex-1`}
87
+ title={cf.label || cf.originalName}
88
+ >
89
+ {cf.label || cf.originalName}
90
+ {detached && (
91
+ <span className="ml-1 text-[9px] text-slate-500 font-normal">
92
+ (not in context)
93
+ </span>
94
+ )}
95
+ </span>
96
+ )}
97
+
98
+ {renamingId === cf.id ? (
99
+ <>
100
+ <input
101
+ autoFocus
102
+ value={renameValue}
103
+ onChange={(e) => setRenameValue(e.target.value)}
104
+ onKeyDown={async (e) => {
105
+ if (e.key === "Enter") {
106
+ e.preventDefault();
107
+ await onCommitRename();
108
+ } else if (e.key === "Escape") {
109
+ onCancelRename();
110
+ }
111
+ }}
112
+ className="w-28 bg-slate-900 border border-cyan-600/50 rounded px-1.5 py-0.5 text-[11px] text-slate-200 outline-none focus:border-cyan-500 shrink-0"
113
+ />
114
+ <button
115
+ onClick={onCommitRename}
116
+ className="shrink-0 p-0.5 rounded text-cyan-400 hover:bg-cyan-600/20 transition-colors"
117
+ title="Confirm"
118
+ >
119
+ <Check className="w-3 h-3" />
120
+ </button>
121
+ <button
122
+ onClick={onCancelRename}
123
+ className="shrink-0 p-0.5 rounded text-slate-500 hover:text-slate-300 transition-colors"
124
+ title="Cancel"
125
+ >
126
+ <X className="w-3 h-3" />
127
+ </button>
128
+ </>
129
+ ) : (
130
+ <>
131
+ {/* Rename */}
132
+ <button
133
+ onClick={() => onStartRename(cf.id, cf.label || cf.originalName)}
134
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-300 transition-all"
135
+ title="Rename"
136
+ >
137
+ <Pencil className="w-3 h-3" />
138
+ </button>
139
+
140
+ {/* Open in lab */}
141
+ <button
142
+ onClick={onOpen}
143
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
144
+ title={openTitle}
145
+ >
146
+ <Play className="w-3 h-3" />
147
+ </button>
148
+
149
+ {/* Attach / Detach context toggle */}
150
+ {detached ? (
151
+ <button
152
+ onClick={onAttach}
153
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-emerald-400 transition-all"
154
+ title="Add back to AI context"
155
+ >
156
+ <LinkIcon className="w-3 h-3" />
157
+ </button>
158
+ ) : (
159
+ <button
160
+ onClick={onDetach}
161
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-amber-400 transition-all"
162
+ title="Remove from AI context (keeps the lab saved)"
163
+ >
164
+ <Link2Off className="w-3 h-3" />
165
+ </button>
166
+ )}
167
+
168
+ {/* Permanently delete */}
169
+ <button
170
+ onClick={onDelete}
171
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
172
+ title="Delete permanently"
173
+ >
174
+ <Trash2 className="w-3 h-3" />
175
+ </button>
176
+ </>
177
+ )}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ // ─── Main panel ──────────────────────────────────────────
183
+
184
+ export default function LabsPanel() {
185
+ const {
186
+ currentQuestion,
187
+ openSandbox,
188
+ openInfraLab,
189
+ openReactLab,
190
+ openNextLab,
191
+ openModuleFederationLab,
192
+ openDeploymentLab,
193
+ removeQuestionFile,
194
+ detachLabFile,
195
+ attachLabFile,
196
+ renameContextFile,
197
+ } = useStore();
198
+
199
+ const [renamingId, setRenamingId] = useState<string | null>(null);
200
+ const [renameValue, setRenameValue] = useState("");
201
+
202
+ const startRename = (id: string, current: string) => {
203
+ setRenamingId(id);
204
+ setRenameValue(current);
205
+ };
206
+
207
+ const commitRename = async (questionId: string, fileId: string) => {
208
+ if (renameValue.trim()) {
209
+ await renameContextFile(questionId, fileId, renameValue.trim());
210
+ }
211
+ setRenamingId(null);
212
+ };
213
+
214
+ const cancelRename = () => setRenamingId(null);
215
+
216
+ const files = (currentQuestion?.contextFiles || []).filter(isLabFile);
217
+
218
+ const byOrigin = (origin: string) => files.filter((f) => f.origin === origin);
219
+
220
+ // ── Open helpers ─────────────────────────────────────────
221
+
222
+ const openSandboxFile = async (cf: ContextFile) => {
223
+ try {
224
+ const raw = await fetch(`/api/context-files/${cf.id}/content`)
225
+ .then((r) => r.json())
226
+ .then((d) => d.content as string);
227
+ const parsed = JSON.parse(raw) as {
228
+ serverCode: string;
229
+ serverLang: string;
230
+ clientCode: string;
231
+ clientLang: string;
232
+ clientType?: "script" | "react" | "nextjs";
233
+ reactFiles?: Record<string, string>;
234
+ reactActiveFile?: string;
235
+ };
236
+ openSandbox(
237
+ parsed.serverCode,
238
+ parsed.serverLang,
239
+ parsed.clientCode,
240
+ parsed.clientLang,
241
+ cf.id,
242
+ parsed.clientType
243
+ ? {
244
+ clientType: parsed.clientType,
245
+ reactFiles: parsed.reactFiles,
246
+ reactActiveFile: parsed.reactActiveFile,
247
+ }
248
+ : undefined,
249
+ );
250
+ } catch {
251
+ /* ignore */
252
+ }
253
+ };
254
+
255
+ const openInfraFile = async (cf: ContextFile) => {
256
+ const raw = await fetch(`/api/context-files/${cf.id}/content`)
257
+ .then((r) => r.json())
258
+ .then((d) => d.content as string);
259
+ const parsed = parseInfraLabWorkspace(raw);
260
+ if (parsed) openInfraLab(parsed, cf.id);
261
+ };
262
+
263
+ const openReactFile = async (cf: ContextFile) => {
264
+ try {
265
+ const raw = await fetch(`/api/context-files/${cf.id}/content`)
266
+ .then((r) => r.json())
267
+ .then((d) => d.content as string);
268
+ const ext = JSON.parse(raw) as {
269
+ clientType?: string;
270
+ reactFiles?: Record<string, string>;
271
+ reactActiveFile?: string;
272
+ serverCode?: string;
273
+ serverLang?: string;
274
+ };
275
+ if (ext?.clientType === "react" && ext.reactFiles) {
276
+ openReactLab(
277
+ {
278
+ version: 1,
279
+ type: "react",
280
+ label: cf.label || "React Lab",
281
+ activeFile:
282
+ ext.reactActiveFile ??
283
+ Object.keys(ext.reactFiles)[0] ??
284
+ "App.tsx",
285
+ files: ext.reactFiles,
286
+ },
287
+ cf.id,
288
+ ext.serverCode,
289
+ ext.serverLang,
290
+ );
291
+ } else {
292
+ const ws = parseFrontendLabWorkspace(raw);
293
+ if (ws) openReactLab(ws, cf.id);
294
+ }
295
+ } catch {
296
+ /* ignore */
297
+ }
298
+ };
299
+
300
+ const openNextFile = async (cf: ContextFile) => {
301
+ try {
302
+ const raw = await fetch(`/api/context-files/${cf.id}/content`)
303
+ .then((r) => r.json())
304
+ .then((d) => d.content as string);
305
+ const ext = JSON.parse(raw) as {
306
+ clientType?: string;
307
+ reactFiles?: Record<string, string>;
308
+ reactActiveFile?: string;
309
+ serverCode?: string;
310
+ serverLang?: string;
311
+ };
312
+ if (ext?.clientType === "nextjs" && ext.reactFiles) {
313
+ openNextLab(
314
+ {
315
+ version: 1,
316
+ type: "nextjs",
317
+ label: cf.label || "Next.js Lab",
318
+ activeFile:
319
+ ext.reactActiveFile ??
320
+ Object.keys(ext.reactFiles)[0] ??
321
+ "app/page.tsx",
322
+ files: ext.reactFiles,
323
+ },
324
+ cf.id,
325
+ ext.serverCode,
326
+ ext.serverLang,
327
+ );
328
+ } else {
329
+ const ws = parseFrontendLabWorkspace(raw);
330
+ if (ws) openNextLab(ws, cf.id);
331
+ }
332
+ } catch {
333
+ /* ignore */
334
+ }
335
+ };
336
+
337
+ const openMFFile = async (cf: ContextFile) => {
338
+ try {
339
+ const raw = await fetch(`/api/context-files/${cf.id}/content`)
340
+ .then((r) => r.json())
341
+ .then((d) => d.content as string);
342
+ const ext = JSON.parse(raw) as {
343
+ clientType?: string;
344
+ reactFiles?: Record<string, string>;
345
+ reactActiveFile?: string;
346
+ serverCode?: string;
347
+ serverLang?: string;
348
+ };
349
+ if (ext?.clientType === "module-federation" && ext.reactFiles) {
350
+ openModuleFederationLab(
351
+ {
352
+ version: 1,
353
+ type: "module-federation",
354
+ label: cf.label || "Webpack Module Federation Lab",
355
+ activeFile:
356
+ ext.reactActiveFile ??
357
+ Object.keys(ext.reactFiles)[0] ??
358
+ "apps/host/webpack.config.js",
359
+ files: ext.reactFiles,
360
+ },
361
+ cf.id,
362
+ ext.serverCode,
363
+ ext.serverLang,
364
+ );
365
+ } else {
366
+ const ws = parseFrontendLabWorkspace(raw);
367
+ if (ws?.type === "module-federation")
368
+ openModuleFederationLab(ws, cf.id);
369
+ }
370
+ } catch {
371
+ /* ignore */
372
+ }
373
+ };
374
+
375
+ // ── Section renderer ─────────────────────────────────────
376
+
377
+ function Section({
378
+ title,
379
+ icon: Icon,
380
+ iconColor,
381
+ origin,
382
+ emptyText,
383
+ onNewLab,
384
+ newLabTitle,
385
+ newLabMenu,
386
+ onOpen,
387
+ openTitle,
388
+ accentClass,
389
+ bgClass,
390
+ }: {
391
+ title: string;
392
+ icon: React.ElementType;
393
+ iconColor: string;
394
+ origin: string;
395
+ emptyText: string;
396
+ onNewLab?: () => void;
397
+ newLabTitle?: string;
398
+ newLabMenu?: Array<{
399
+ label: string;
400
+ description: string;
401
+ onClick: () => void;
402
+ }>;
403
+ onOpen: (cf: ContextFile) => void;
404
+ openTitle: string;
405
+ accentClass: string;
406
+ bgClass: string;
407
+ }) {
408
+ const [menuOpen, setMenuOpen] = useState(false);
409
+ const items = byOrigin(origin);
410
+ if (!currentQuestion) return null;
411
+
412
+ return (
413
+ <div className="border-t border-slate-800 px-3 py-2">
414
+ <div className="flex items-center justify-between mb-1">
415
+ <div className="flex items-center gap-1">
416
+ <Icon className={`w-3 h-3 ${iconColor}`} />
417
+ <span className="text-[10px] uppercase tracking-wider text-slate-600">
418
+ {title} ({items.length})
419
+ </span>
420
+ </div>
421
+ {newLabMenu ? (
422
+ <div className="relative">
423
+ <button
424
+ onClick={() => setMenuOpen((o) => !o)}
425
+ className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
426
+ title="New lab"
427
+ >
428
+ <Plus className="w-3.5 h-3.5" />
429
+ </button>
430
+ {menuOpen && (
431
+ <>
432
+ {/* backdrop to close on outside click */}
433
+ <div
434
+ className="fixed inset-0 z-40"
435
+ onClick={() => setMenuOpen(false)}
436
+ />
437
+ <div className="absolute right-0 top-full mt-1 z-50 bg-slate-800 border border-slate-600 rounded-lg shadow-xl w-52 overflow-hidden">
438
+ {newLabMenu.map((item) => (
439
+ <button
440
+ key={item.label}
441
+ onClick={() => {
442
+ item.onClick();
443
+ setMenuOpen(false);
444
+ }}
445
+ className="w-full text-left px-3 py-2 hover:bg-slate-700 transition-colors group"
446
+ >
447
+ <div className="text-[11px] font-medium text-slate-200 group-hover:text-cyan-300">
448
+ {item.label}
449
+ </div>
450
+ <div className="text-[10px] text-slate-500 mt-0.5 leading-snug">
451
+ {item.description}
452
+ </div>
453
+ </button>
454
+ ))}
455
+ </div>
456
+ </>
457
+ )}
458
+ </div>
459
+ ) : onNewLab ? (
460
+ <button
461
+ onClick={onNewLab}
462
+ className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
463
+ title={newLabTitle}
464
+ >
465
+ <Plus className="w-3.5 h-3.5" />
466
+ </button>
467
+ ) : null}
468
+ </div>
469
+ <div className="space-y-0.5 max-h-40 overflow-y-auto">
470
+ {items.map((cf) => (
471
+ <LabItem
472
+ key={cf.id}
473
+ cf={cf}
474
+ questionId={currentQuestion.id}
475
+ accentClass={accentClass}
476
+ bgClass={bgClass}
477
+ renamingId={renamingId}
478
+ renameValue={renameValue}
479
+ setRenameValue={setRenameValue}
480
+ onStartRename={startRename}
481
+ onCommitRename={() => commitRename(currentQuestion.id, cf.id)}
482
+ onCancelRename={cancelRename}
483
+ onOpen={() => onOpen(cf)}
484
+ onDetach={() => detachLabFile(currentQuestion.id, cf.id)}
485
+ onAttach={() => attachLabFile(currentQuestion.id, cf.id)}
486
+ onDelete={() => {
487
+ if (
488
+ window.confirm(
489
+ `Permanently delete "${cf.label || cf.originalName}"?`,
490
+ )
491
+ ) {
492
+ removeQuestionFile(currentQuestion.id, cf.id);
493
+ }
494
+ }}
495
+ openTitle={openTitle}
496
+ />
497
+ ))}
498
+ {items.length === 0 && (
499
+ <p className="text-[10px] text-slate-700 italic">{emptyText}</p>
500
+ )}
501
+ </div>
502
+ </div>
503
+ );
504
+ }
505
+
506
+ return (
507
+ <div className="w-72 h-full min-h-0 border-l border-slate-800 flex flex-col bg-slate-900/30 shrink-0 overflow-hidden">
508
+ {/* Header */}
509
+ <div className="border-b border-slate-800 px-3 py-2 flex items-center gap-1.5">
510
+ <FlaskConical className="w-3.5 h-3.5 text-cyan-400/70" />
511
+ <span className="text-xs font-bold uppercase tracking-wider text-slate-500 flex-1">
512
+ Labs
513
+ </span>
514
+ {!currentQuestion && (
515
+ <span className="text-[10px] text-slate-600">Select a question</span>
516
+ )}
517
+ </div>
518
+
519
+ {currentQuestion ? (
520
+ <div className="flex-1 min-h-0 overflow-y-auto">
521
+ <Section
522
+ title="Sandboxes"
523
+ icon={Server}
524
+ iconColor="text-slate-400/70"
525
+ origin="sandbox"
526
+ emptyText="Save a sandbox to see it here"
527
+ onNewLab={() => openSandbox("", "javascript", "", "javascript")}
528
+ newLabTitle="Open Sandbox"
529
+ onOpen={openSandboxFile}
530
+ openTitle="Open in Sandbox"
531
+ accentClass="text-slate-300"
532
+ bgClass="bg-slate-500/10 border border-slate-500/20"
533
+ />
534
+ <Section
535
+ title="Infra Labs"
536
+ icon={Globe}
537
+ iconColor="text-cyan-400/70"
538
+ origin="infra"
539
+ emptyText="Save an infra lab to reopen it here"
540
+ onNewLab={() => openInfraLab()}
541
+ newLabTitle="Open Infrastructure Lab"
542
+ onOpen={openInfraFile}
543
+ openTitle="Open in Infrastructure Lab"
544
+ accentClass="text-cyan-200"
545
+ bgClass="bg-cyan-500/10 border border-cyan-500/20"
546
+ />
547
+ <Section
548
+ title="React Labs"
549
+ icon={Atom}
550
+ iconColor="text-cyan-400/70"
551
+ origin="react"
552
+ emptyText="Save a React lab to reopen it here"
553
+ onNewLab={() => openReactLab()}
554
+ newLabTitle="Open React Lab"
555
+ onOpen={openReactFile}
556
+ openTitle="Open in React Lab"
557
+ accentClass="text-cyan-200"
558
+ bgClass="bg-cyan-500/10 border border-cyan-500/20"
559
+ />
560
+ <Section
561
+ title="Next.js Labs"
562
+ icon={Layout}
563
+ iconColor="text-violet-400/70"
564
+ origin="nextjs"
565
+ emptyText="Save a Next.js lab to reopen it here"
566
+ onNewLab={() => openNextLab()}
567
+ newLabTitle="Open Next.js Lab"
568
+ onOpen={openNextFile}
569
+ openTitle="Open in Next.js Lab"
570
+ accentClass="text-violet-200"
571
+ bgClass="bg-violet-500/10 border border-violet-500/20"
572
+ />
573
+ <Section
574
+ title="Webpack MF Labs"
575
+ icon={Server}
576
+ iconColor="text-emerald-400/70"
577
+ origin="module-federation"
578
+ emptyText="Save a webpack module federation lab to reopen it here"
579
+ newLabMenu={[
580
+ {
581
+ label: "Shared React Tree",
582
+ description:
583
+ "Shell & remote share one React runtime — classic federation",
584
+ onClick: () => openModuleFederationLab(),
585
+ },
586
+ {
587
+ label: "Isolated Mount / Unmount",
588
+ description:
589
+ "Remote owns its own React root via mount(el) / unmount(el)",
590
+ onClick: () =>
591
+ openModuleFederationLab(ISOLATED_MODULE_FEDERATION_LAB),
592
+ },
593
+ ]}
594
+ onOpen={openMFFile}
595
+ openTitle="Open in Webpack Module Federation Lab"
596
+ accentClass="text-emerald-200"
597
+ bgClass="bg-emerald-500/10 border border-emerald-500/20"
598
+ />
599
+ </div>
600
+ ) : (
601
+ <div className="flex-1 flex items-center justify-center">
602
+ <p className="text-xs text-slate-600">Select a question first</p>
603
+ </div>
604
+ )}
605
+
606
+ {/* Deployment Lab (standalone — no context files) */}
607
+ <div className="border-t border-slate-700/50 p-3 shrink-0">
608
+ <div className="flex items-center gap-2 mb-1.5">
609
+ <Network className="w-3.5 h-3.5 text-violet-400/70" />
610
+ <span className="text-[10px] font-semibold tracking-widest text-slate-400">
611
+ DEPLOYMENT LAB
612
+ </span>
613
+ </div>
614
+ <p className="text-[10px] text-slate-600 mb-2 italic leading-snug">
615
+ Simulate rolling, canary, blue-green &amp; more
616
+ </p>
617
+ <button
618
+ onClick={openDeploymentLab}
619
+ className="text-[11px] px-2.5 py-1 rounded bg-violet-500/15 border border-violet-500/25 text-violet-300 hover:bg-violet-500/25 transition-colors"
620
+ >
621
+ Open Deployment Lab
622
+ </button>
623
+ </div>
624
+ </div>
625
+ );
626
+ }