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.
- package/package.json +1 -1
- package/template/client/src/App.tsx +30 -1
- package/template/client/src/api.ts +22 -0
- package/template/client/src/components/CodeContextPanel.tsx +0 -622
- package/template/client/src/components/DeploymentLabModal.tsx +1941 -0
- package/template/client/src/components/LabsPanel.tsx +565 -0
- package/template/client/src/components/Sidebar.tsx +97 -55
- package/template/client/src/store.ts +52 -1
- package/template/client/src/types.ts +2 -0
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +35 -1
- package/template/server/src/storage.ts +31 -0
|
@@ -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
|
-
|
|
385
|
-
|
|
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
|
-
<
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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) => ({
|
|
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 {
|
package/template/cockpit.json
CHANGED
|
@@ -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[],
|