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