create-interview-cockpit 0.11.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.
@@ -19,6 +19,7 @@ import {
19
19
  Globe,
20
20
  SlidersHorizontal,
21
21
  ArrowRightLeft,
22
+ MoreHorizontal,
22
23
  } from "lucide-react";
23
24
 
24
25
  const ROOT_PARENT_VALUE = "__root__";
@@ -75,6 +76,9 @@ export default function Sidebar() {
75
76
  const [collapsedQuestions, setCollapsedQuestions] = useState<Set<string>>(
76
77
  new Set(),
77
78
  );
79
+ const [openMenuQuestionId, setOpenMenuQuestionId] = useState<string | null>(
80
+ null,
81
+ );
78
82
  const [openTopicPrompts, setOpenTopicPrompts] = useState<Set<string>>(
79
83
  new Set(),
80
84
  );
@@ -320,6 +324,7 @@ export default function Sidebar() {
320
324
  ) => {
321
325
  // 12px base left padding + 16px per depth level
322
326
  const paddingLeft = 12 + depth * 16;
327
+ const isMenuOpen = openMenuQuestionId === q.id;
323
328
  return (
324
329
  <div
325
330
  key={q.id}
@@ -371,7 +376,8 @@ export default function Sidebar() {
371
376
  />
372
377
  ) : (
373
378
  <span
374
- className="text-xs text-slate-400 truncate flex-1"
379
+ className="text-xs text-slate-400 truncate flex-1 min-w-0"
380
+ title={q.title}
375
381
  onDoubleClick={(e) => {
376
382
  e.stopPropagation();
377
383
  setEditingQuestionId(q.id);
@@ -381,63 +387,99 @@ export default function Sidebar() {
381
387
  {q.title}
382
388
  </span>
383
389
  )}
384
- <span className="text-[10px] text-slate-700 shrink-0">
385
- {q.messages.length > 0 ? `${q.messages.length}` : ""}
386
- </span>
387
- {editingQuestionId !== q.id && (
388
- <button
389
- onClick={(e) => {
390
- e.stopPropagation();
391
- setAddingChildTo(q.id);
392
- setNewChildTitle("");
393
- }}
394
- className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
395
- title="Add child question"
396
- >
397
- <CornerDownRight className="w-2.5 h-2.5" />
398
- </button>
399
- )}
400
- {editingQuestionId !== q.id && (
401
- <button
402
- onClick={(e) => {
403
- e.stopPropagation();
404
- setEditingQuestionId(q.id);
405
- setEditingQuestionTitle(q.title);
406
- }}
407
- className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
408
- title="Rename"
409
- >
410
- <Pencil className="w-2.5 h-2.5" />
411
- </button>
412
- )}
390
+
391
+ {/* Right side: count fades on hover, replaced by "..." menu */}
413
392
  {editingQuestionId !== q.id && (
414
- <button
415
- onClick={(e) => {
416
- e.stopPropagation();
417
- setMovingQuestionId((prev) => (prev === q.id ? null : q.id));
418
- setMoveTargetParentId(q.parentQuestionId ?? ROOT_PARENT_VALUE);
419
- }}
420
- className={`p-0.5 rounded opacity-0 group-hover:opacity-100 transition-all ${
421
- movingQuestionId === q.id
422
- ? "opacity-100 text-cyan-400"
423
- : "text-slate-600 hover:text-cyan-400"
424
- }`}
425
- title="Move to a different parent"
393
+ <div
394
+ className="relative shrink-0 flex items-center"
395
+ onClick={(e) => e.stopPropagation()}
426
396
  >
427
- <ArrowRightLeft className="w-2.5 h-2.5" />
428
- </button>
397
+ {/* Count hidden while hovering or when menu is open */}
398
+ <span
399
+ className={`text-[10px] text-slate-700 ${
400
+ isMenuOpen ? "hidden" : "group-hover:hidden"
401
+ }`}
402
+ >
403
+ {q.messages.length > 0 ? `${q.messages.length}` : ""}
404
+ </span>
405
+
406
+ {/* "..." button — shown on hover or while menu is open */}
407
+ <button
408
+ onClick={() => setOpenMenuQuestionId(isMenuOpen ? null : q.id)}
409
+ className={`p-0.5 rounded transition-all ${
410
+ isMenuOpen
411
+ ? "text-cyan-400"
412
+ : "opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300"
413
+ }`}
414
+ title="More options"
415
+ >
416
+ <MoreHorizontal className="w-3.5 h-3.5" />
417
+ </button>
418
+
419
+ {/* Dropdown */}
420
+ {isMenuOpen && (
421
+ <>
422
+ {/* Backdrop — closes menu when clicking outside */}
423
+ <div
424
+ className="fixed inset-0 z-40"
425
+ onClick={() => setOpenMenuQuestionId(null)}
426
+ />
427
+ <div className="absolute right-0 top-full mt-0.5 z-50 bg-slate-800 border border-slate-700 rounded-md shadow-xl min-w-[140px] py-0.5">
428
+ <button
429
+ onClick={() => {
430
+ setOpenMenuQuestionId(null);
431
+ setEditingQuestionId(q.id);
432
+ setEditingQuestionTitle(q.title);
433
+ }}
434
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
435
+ >
436
+ <Pencil className="w-3 h-3" /> Rename
437
+ </button>
438
+ <button
439
+ onClick={() => {
440
+ setOpenMenuQuestionId(null);
441
+ setAddingChildTo(q.id);
442
+ setNewChildTitle("");
443
+ }}
444
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
445
+ >
446
+ <CornerDownRight className="w-3 h-3" /> Add child
447
+ </button>
448
+ <button
449
+ onClick={() => {
450
+ setOpenMenuQuestionId(null);
451
+ setMovingQuestionId((prev) =>
452
+ prev === q.id ? null : q.id,
453
+ );
454
+ setMoveTargetParentId(
455
+ q.parentQuestionId ?? ROOT_PARENT_VALUE,
456
+ );
457
+ }}
458
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
459
+ >
460
+ <ArrowRightLeft className="w-3 h-3" /> Move
461
+ </button>
462
+ <div className="border-t border-slate-700 my-0.5" />
463
+ <button
464
+ onClick={() => {
465
+ setOpenMenuQuestionId(null);
466
+ if (
467
+ window.confirm(
468
+ `Delete "${q.title}"? This cannot be undone.`,
469
+ )
470
+ ) {
471
+ removeQuestion(q.id, topicId);
472
+ }
473
+ }}
474
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-slate-700 hover:text-red-300 transition-colors"
475
+ >
476
+ <Trash2 className="w-3 h-3" /> Delete
477
+ </button>
478
+ </div>
479
+ </>
480
+ )}
481
+ </div>
429
482
  )}
430
- <button
431
- onClick={(e) => {
432
- e.stopPropagation();
433
- if (window.confirm(`Delete "${q.title}"? This cannot be undone.`)) {
434
- removeQuestion(q.id, topicId);
435
- }
436
- }}
437
- className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
438
- >
439
- <Trash2 className="w-2.5 h-2.5" />
440
- </button>
441
483
  </div>
442
484
  );
443
485
  };
@@ -5,7 +5,65 @@ export type FrontendLabType = FrontendLabWorkspace["type"];
5
5
  // ── Default file contents ────────────────────────────────────────────────────
6
6
 
7
7
  const REACT_DEFAULT_FILES: Record<string, string> = {
8
- "App.tsx": `import { useState } from "react";
8
+ "package.json": `{
9
+ "name": "react-lab",
10
+ "private": true,
11
+ "version": "0.0.0",
12
+ "type": "module",
13
+ "scripts": {
14
+ "dev": "vite",
15
+ "build": "tsc -b && vite build",
16
+ "preview": "vite preview"
17
+ },
18
+ "dependencies": {
19
+ "react": "^18.3.1",
20
+ "react-dom": "^18.3.1"
21
+ },
22
+ "devDependencies": {
23
+ "@types/react": "^18.3.1",
24
+ "@types/react-dom": "^18.3.1",
25
+ "@vitejs/plugin-react": "^4.3.4",
26
+ "typescript": "^5.6.2",
27
+ "vite": "^6.0.3"
28
+ }
29
+ }
30
+ `,
31
+ "vite.config.ts": `import { defineConfig } from "vite";
32
+ import react from "@vitejs/plugin-react";
33
+
34
+ export default defineConfig({
35
+ plugins: [react()],
36
+ server: {
37
+ headers: {
38
+ "X-Frame-Options": "ALLOWALL",
39
+ },
40
+ },
41
+ });
42
+ `,
43
+ "index.html": `<!DOCTYPE html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="UTF-8" />
47
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
48
+ <title>React Lab</title>
49
+ </head>
50
+ <body>
51
+ <div id="root"></div>
52
+ <script type="module" src="/src/main.tsx"></script>
53
+ </body>
54
+ </html>
55
+ `,
56
+ "src/main.tsx": `import React from "react";
57
+ import ReactDOM from "react-dom/client";
58
+ import App from "./App";
59
+
60
+ ReactDOM.createRoot(document.getElementById("root")!).render(
61
+ <React.StrictMode>
62
+ <App />
63
+ </React.StrictMode>,
64
+ );
65
+ `,
66
+ "src/App.tsx": `import { useState } from "react";
9
67
  import { Counter } from "./Counter";
10
68
  import type { User } from "./types";
11
69
 
@@ -30,7 +88,7 @@ export default function App() {
30
88
  );
31
89
  }
32
90
  `,
33
- "Counter.tsx": `import { useState, useCallback } from "react";
91
+ "src/Counter.tsx": `import { useState, useCallback } from "react";
34
92
  import type { CounterProps } from "./types";
35
93
 
36
94
  // Stateful child component — receives props from App
@@ -88,7 +146,7 @@ export function Counter({ initialCount = 0, onCountChange }: CounterProps) {
88
146
  );
89
147
  }
90
148
  `,
91
- "types.ts": `// Type definitions — shared across components
149
+ "src/types.ts": `// Type definitions — shared across components
92
150
 
93
151
  export interface User {
94
152
  name: string;
@@ -1065,7 +1123,7 @@ export const DEFAULT_REACT_LAB: FrontendLabWorkspace = {
1065
1123
  version: 1,
1066
1124
  label: "React Lab",
1067
1125
  type: "react",
1068
- activeFile: "App.tsx",
1126
+ activeFile: "src/App.tsx",
1069
1127
  files: REACT_DEFAULT_FILES,
1070
1128
  };
1071
1129
 
@@ -1175,9 +1233,11 @@ export function getEntryFile(workspace: FrontendLabWorkspace): string {
1175
1233
  ? "apps/host/src/App.jsx"
1176
1234
  : Object.keys(workspace.files)[0];
1177
1235
  }
1178
- return workspace.files["App.tsx"]
1179
- ? "App.tsx"
1180
- : Object.keys(workspace.files)[0];
1236
+ return workspace.files["main.tsx"]
1237
+ ? "main.tsx"
1238
+ : workspace.files["App.tsx"]
1239
+ ? "App.tsx"
1240
+ : Object.keys(workspace.files)[0];
1181
1241
  }
1182
1242
 
1183
1243
  /** Preferred display order for the file tree. */
@@ -1228,7 +1288,8 @@ export function resolveNextjsEntry(
1228
1288
  *
1229
1289
  * Approach: loads React 18 UMD + Babel standalone from CDN, runs a
1230
1290
  * custom module system built on top of Babel's CJS transform plugin,
1231
- * then renders the default export from `entryFile`.
1291
+ * then either runs a bootstrap entry such as main.tsx or renders the
1292
+ * default export from `entryFile`.
1232
1293
  *
1233
1294
  * CDN URLs are version-pinned so the preview is reproducible.
1234
1295
  */
@@ -1242,9 +1303,6 @@ export function generatePreviewHTML(
1242
1303
  const entryJSON = JSON.stringify(entryFile);
1243
1304
  const sandboxJSON = JSON.stringify(sandboxUrl ?? "");
1244
1305
  const isNextjsJSON = isNextjs ? "true" : "false";
1245
- // _i breaks up the 'import' keyword so Vite/Babel doesn't misparse
1246
- // the template literal below as containing real module import declarations
1247
- const _i = "import";
1248
1306
 
1249
1307
  return `<!DOCTYPE html>
1250
1308
  <html>
@@ -1252,6 +1310,8 @@ export function generatePreviewHTML(
1252
1310
  <meta charset="utf-8">
1253
1311
  <meta name="viewport" content="width=device-width, initial-scale=1">
1254
1312
  <script>window.__F__=${filesJSON};window.__E__=${entryJSON};window.SANDBOX_URL=${sandboxJSON};window.__NX__=${isNextjsJSON};</script>
1313
+ <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
1314
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
1255
1315
  <script src="https://unpkg.com/@babel/standalone@7.26.10/babel.min.js"></script>
1256
1316
  <style>
1257
1317
  *{box-sizing:border-box}
@@ -1262,11 +1322,7 @@ body{margin:0;background:#fff;font-family:system-ui,sans-serif}
1262
1322
  <body>
1263
1323
  <div id="root"></div>
1264
1324
  <div id="__err"></div>
1265
- <script type="module">
1266
- ${_i} React from 'https://esm.sh/react@19.1.0';
1267
- ${_i} * as ReactDOM from 'https://esm.sh/react-dom@19.1.0/client?deps=react@19.1.0';
1268
- window.React = React;
1269
- window.ReactDOM = ReactDOM;
1325
+ <script>
1270
1326
  (function(){
1271
1327
  var files=window.__F__,entry=window.__E__,reg={};
1272
1328
  function norm(from,id){
@@ -1329,27 +1385,36 @@ window.ReactDOM = ReactDOM;
1329
1385
  window.addEventListener('unhandledrejection',function(e){showErr(e.reason&&e.reason.message?e.reason.message:String(e.reason));});
1330
1386
  try{
1331
1387
  order.forEach(loadMod);
1332
- var em=reg[entry];
1333
- if(!em)throw new Error('Entry not found: '+entry);
1334
- var Comp=em.exports.default;
1335
- if(typeof Comp!=='function')throw new Error('No default export (function/component) in '+entry);
1336
1388
  // Expose a navigate helper so in-preview code can trigger URL bar changes:
1337
1389
  // window.__nxNavigate('/dashboard')
1338
1390
  window.__nxNavigate=function(to){try{parent.postMessage({type:'rlab-nav',to:to},'*');}catch(e){}};
1339
- var pageEl=React.createElement(Comp,null);
1340
- // In Next.js mode: wrap the page in app/layout.tsx if it exists
1341
- if(window.__NX__){
1342
- var lk=null;
1343
- for(var _le of['app/layout.tsx','app/layout.ts','app/layout.jsx','app/layout.js']){
1344
- if(reg[_le]){lk=_le;break;}
1391
+ var em=reg[entry];
1392
+ if(!em)throw new Error('Entry not found: '+entry);
1393
+ var isBootstrapEntry=/(^|\\/)(main|index)\\.(tsx|ts|jsx|js)$/.test(entry);
1394
+ if(isBootstrapEntry){
1395
+ if(typeof em.exports.mount==='function'){
1396
+ var mountRoot=document.getElementById('root');
1397
+ if(!mountRoot)throw new Error('Root element #root not found');
1398
+ em.exports.mount(mountRoot);
1345
1399
  }
1346
- if(lk&&typeof reg[lk].exports.default==='function'){
1347
- pageEl=React.createElement(reg[lk].exports.default,null,pageEl);
1400
+ }else{
1401
+ var Comp=em.exports.default;
1402
+ if(typeof Comp!=='function')throw new Error('No default export (function/component) in '+entry);
1403
+ var pageEl=React.createElement(Comp,null);
1404
+ // In Next.js mode: wrap the page in app/layout.tsx if it exists
1405
+ if(window.__NX__){
1406
+ var lk=null;
1407
+ for(var _le of['app/layout.tsx','app/layout.ts','app/layout.jsx','app/layout.js']){
1408
+ if(reg[_le]){lk=_le;break;}
1409
+ }
1410
+ if(lk&&typeof reg[lk].exports.default==='function'){
1411
+ pageEl=React.createElement(reg[lk].exports.default,null,pageEl);
1412
+ }
1348
1413
  }
1414
+ ReactDOM.createRoot(document.getElementById('root')).render(
1415
+ React.createElement(React.StrictMode,null,pageEl)
1416
+ );
1349
1417
  }
1350
- ReactDOM.createRoot(document.getElementById('root')).render(
1351
- React.createElement(React.StrictMode,null,pageEl)
1352
- );
1353
1418
  try{parent.postMessage({type:'rlab-ready'},'*');}catch(e){}
1354
1419
  }catch(err){showErr(err.message+(err.stack?'\\n\\n'+err.stack:''));}
1355
1420
  })();
@@ -106,6 +106,7 @@ interface Store {
106
106
  expandedTopics: string[];
107
107
  availableFiles: string[];
108
108
  showCodePanel: boolean;
109
+ showLabsPanel: boolean;
109
110
  showSidebar: boolean;
110
111
  viewingFile: string | null;
111
112
  viewingDoc: { fileId: string; quote: string; fileName: string } | null;
@@ -183,6 +184,7 @@ interface Store {
183
184
  selectQuestion: (topicId: string, questionId: string) => Promise<void>;
184
185
  toggleTopic: (topicId: string) => void;
185
186
  toggleCodePanel: () => void;
187
+ toggleLabsPanel: () => void;
186
188
  toggleSidebar: () => void;
187
189
  fetchAvailableFiles: () => Promise<void>;
188
190
  updateCodeContext: (questionId: string, files: string[]) => Promise<void>;
@@ -202,6 +204,8 @@ interface Store {
202
204
  files: FileList | File[],
203
205
  ) => Promise<void>;
204
206
  removeQuestionFile: (questionId: string, fileId: string) => Promise<void>;
207
+ detachLabFile: (questionId: string, fileId: string) => Promise<void>;
208
+ attachLabFile: (questionId: string, fileId: string) => Promise<void>;
205
209
  linkFileToQuestion: (
206
210
  questionId: string,
207
211
  fileId: string,
@@ -311,6 +315,11 @@ interface Store {
311
315
  ) => Promise<void>;
312
316
  closeCodeRunner: () => void;
313
317
 
318
+ // ── Deployment Lab ──────────────────────────────────────────
319
+ showDeploymentLab: boolean;
320
+ openDeploymentLab: () => void;
321
+ closeDeploymentLab: () => void;
322
+
314
323
  // ── Infra Lab ────────────────────────────────────────────────
315
324
  showInfraLab: boolean;
316
325
  runnerInitialInfra: InfraLabWorkspace | null;
@@ -349,6 +358,7 @@ export const useStore = create<Store>((set, get) => ({
349
358
  expandedTopics: [],
350
359
  availableFiles: [],
351
360
  showCodePanel: false,
361
+ showLabsPanel: false,
352
362
  showSidebar: true,
353
363
  viewingFile: null,
354
364
  viewingDoc: null,
@@ -362,6 +372,7 @@ export const useStore = create<Store>((set, get) => ({
362
372
  runnerInitialLanguage: "typescript",
363
373
  runnerInitialSandbox: null,
364
374
  runnerInitialFileId: null,
375
+ showDeploymentLab: false,
365
376
  showInfraLab: false,
366
377
  runnerInitialInfra: null,
367
378
  runnerInitialInfraFileId: null,
@@ -671,13 +682,20 @@ export const useStore = create<Store>((set, get) => ({
671
682
  },
672
683
 
673
684
  toggleCodePanel: () => {
674
- set((s) => ({ showCodePanel: !s.showCodePanel }));
685
+ set((s) => ({
686
+ showCodePanel: !s.showCodePanel,
687
+ showLabsPanel: false,
688
+ }));
675
689
  const { availableFiles, fetchAvailableFiles } = get();
676
690
  if (availableFiles.length === 0) {
677
691
  fetchAvailableFiles();
678
692
  }
679
693
  },
680
694
 
695
+ toggleLabsPanel: () => {
696
+ set((s) => ({ showLabsPanel: !s.showLabsPanel, showCodePanel: false }));
697
+ },
698
+
681
699
  fetchAvailableFiles: async () => {
682
700
  const files = await api.fetchCodeContextTree();
683
701
  set({ availableFiles: files });
@@ -769,6 +787,36 @@ export const useStore = create<Store>((set, get) => ({
769
787
  }));
770
788
  },
771
789
 
790
+ detachLabFile: async (questionId, fileId) => {
791
+ const cf = await api.detachQuestionLabFile(questionId, fileId);
792
+ set((s) => ({
793
+ currentQuestion:
794
+ s.currentQuestion?.id === questionId
795
+ ? {
796
+ ...s.currentQuestion,
797
+ contextFiles: (s.currentQuestion.contextFiles || []).map((f) =>
798
+ f.id === fileId ? { ...f, inContext: cf.inContext } : f,
799
+ ),
800
+ }
801
+ : s.currentQuestion,
802
+ }));
803
+ },
804
+
805
+ attachLabFile: async (questionId, fileId) => {
806
+ const cf = await api.attachQuestionLabFile(questionId, fileId);
807
+ set((s) => ({
808
+ currentQuestion:
809
+ s.currentQuestion?.id === questionId
810
+ ? {
811
+ ...s.currentQuestion,
812
+ contextFiles: (s.currentQuestion.contextFiles || []).map((f) =>
813
+ f.id === fileId ? { ...f, inContext: cf.inContext } : f,
814
+ ),
815
+ }
816
+ : s.currentQuestion,
817
+ }));
818
+ },
819
+
772
820
  linkFileToQuestion: async (questionId, fileId, originalName) => {
773
821
  const cf = await api.linkFileToQuestion(questionId, fileId, originalName);
774
822
  set((s) => ({
@@ -1010,6 +1058,9 @@ export const useStore = create<Store>((set, get) => ({
1010
1058
  }));
1011
1059
  },
1012
1060
  closeCodeRunner: () => set({ showCodeRunner: false }),
1061
+ showDeploymentLab: false,
1062
+ openDeploymentLab: () => set({ showDeploymentLab: true }),
1063
+ closeDeploymentLab: () => set({ showDeploymentLab: false }),
1013
1064
  closeInfraLab: () => set({ showInfraLab: false }),
1014
1065
 
1015
1066
  fetchAiSettings: async () => {
@@ -23,6 +23,8 @@ export interface ContextFile {
23
23
  language?: string;
24
24
  /** Short display label for code snippets. */
25
25
  label?: string;
26
+ /** When false, file is saved but excluded from AI prompt context (detached lab). */
27
+ inContext?: boolean;
26
28
  }
27
29
 
28
30
  export interface FrontendLabWorkspace {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.9.0"
2
+ "version": "0.12.0"
3
3
  }