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.
@@ -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
  };
@@ -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.10.0"
2
+ "version": "0.12.0"
3
3
  }
@@ -684,6 +684,38 @@ app.delete(
684
684
  },
685
685
  );
686
686
 
687
+ // Detach a lab file from AI context without deleting it
688
+ app.post(
689
+ "/api/questions/:questionId/context-files/:fileId/detach",
690
+ async (req, res) => {
691
+ try {
692
+ const cf = await storage.detachQuestionContextFile(
693
+ req.params.questionId,
694
+ req.params.fileId,
695
+ );
696
+ res.json(cf);
697
+ } catch (err: any) {
698
+ res.status(500).json({ error: err?.message || "Failed to detach" });
699
+ }
700
+ },
701
+ );
702
+
703
+ // Re-attach a previously detached lab file to AI context
704
+ app.post(
705
+ "/api/questions/:questionId/context-files/:fileId/attach",
706
+ async (req, res) => {
707
+ try {
708
+ const cf = await storage.attachQuestionContextFile(
709
+ req.params.questionId,
710
+ req.params.fileId,
711
+ );
712
+ res.json(cf);
713
+ } catch (err: any) {
714
+ res.status(500).json({ error: err?.message || "Failed to attach" });
715
+ }
716
+ },
717
+ );
718
+
687
719
  // Save a code snippet (from Code Runner or AI response) as a question context file
688
720
  app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
689
721
  const { code, language, label, origin } = req.body as {
@@ -1376,7 +1408,9 @@ app.post("/api/chat", async (req, res) => {
1376
1408
  if (questionId) {
1377
1409
  const question = await storage.getQuestion(questionId);
1378
1410
  if (question?.contextFiles?.length) {
1379
- for (const cf of question.contextFiles) {
1411
+ for (const cf of question.contextFiles.filter(
1412
+ (c) => c.inContext !== false,
1413
+ )) {
1380
1414
  fileRegistry.set(cf.id, {
1381
1415
  label: `[question] ${cf.originalName}`,
1382
1416
  reader: () => storage.readContextFileContent(cf.id),
@@ -76,6 +76,9 @@ export interface ContextFile {
76
76
  language?: string;
77
77
  /** Short display label for code snippets. */
78
78
  label?: string;
79
+ /** When false, the file is saved but excluded from the AI prompt context.
80
+ * Used to detach lab files without permanently deleting them. */
81
+ inContext?: boolean;
79
82
  }
80
83
 
81
84
  export interface Message {
@@ -802,6 +805,34 @@ export async function deleteQuestionContextFile(
802
805
  }
803
806
  }
804
807
 
808
+ /** Removes a lab file from the AI context without deleting it from disk. */
809
+ export async function detachQuestionContextFile(
810
+ questionId: string,
811
+ fileId: string,
812
+ ): Promise<ContextFile> {
813
+ const q = await getQuestion(questionId);
814
+ if (!q) throw new Error("Question not found");
815
+ const cf = (q.contextFiles || []).find((f) => f.id === fileId);
816
+ if (!cf) throw new Error("Context file not found");
817
+ cf.inContext = false;
818
+ await saveQuestion(q);
819
+ return cf;
820
+ }
821
+
822
+ /** Re-attaches a detached lab file to the AI context. */
823
+ export async function attachQuestionContextFile(
824
+ questionId: string,
825
+ fileId: string,
826
+ ): Promise<ContextFile> {
827
+ const q = await getQuestion(questionId);
828
+ if (!q) throw new Error("Question not found");
829
+ const cf = (q.contextFiles || []).find((f) => f.id === fileId);
830
+ if (!cf) throw new Error("Context file not found");
831
+ cf.inContext = true;
832
+ await saveQuestion(q);
833
+ return cf;
834
+ }
835
+
805
836
  export async function updateQuestionMessages(
806
837
  questionId: string,
807
838
  messages: Message[],