create-interview-cockpit 0.4.0 → 0.6.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/package-lock.json +753 -1
- package/template/client/package.json +4 -0
- package/template/client/src/App.tsx +20 -0
- package/template/client/src/api.ts +455 -3
- package/template/client/src/components/AiSettingsModal.tsx +855 -248
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +132 -27
- package/template/client/src/components/ChatView.tsx +365 -123
- package/template/client/src/components/CodeContextPanel.tsx +714 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
- package/template/client/src/components/DocRefModal.tsx +551 -0
- package/template/client/src/components/FileAttachments.tsx +128 -12
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +219 -2
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +397 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +412 -25
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +477 -0
- package/template/client/src/store.ts +416 -2
- package/template/client/src/types.ts +41 -1
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +144 -2
- package/template/server/src/index.ts +1890 -188
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +274 -3
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useEffect, useState, useCallback } from "react";
|
|
2
2
|
import { useStore } from "../store";
|
|
3
|
+
import { parseInfraLabWorkspace } from "../infraLab";
|
|
4
|
+
import { parseFrontendLabWorkspace } from "../reactLab";
|
|
3
5
|
import FileViewerModal from "./FileViewerModal";
|
|
6
|
+
import NotesModal, { notesKey } from "./NotesModal";
|
|
4
7
|
import {
|
|
5
8
|
File,
|
|
6
9
|
Search,
|
|
@@ -15,6 +18,17 @@ import {
|
|
|
15
18
|
MinusSquare,
|
|
16
19
|
Eye,
|
|
17
20
|
Scissors,
|
|
21
|
+
Terminal,
|
|
22
|
+
Sparkles,
|
|
23
|
+
Plus,
|
|
24
|
+
Play,
|
|
25
|
+
Trash2,
|
|
26
|
+
Server,
|
|
27
|
+
Pencil,
|
|
28
|
+
NotebookPen,
|
|
29
|
+
Globe,
|
|
30
|
+
Atom,
|
|
31
|
+
Layout,
|
|
18
32
|
} from "lucide-react";
|
|
19
33
|
|
|
20
34
|
// ─── Tree data structure ─────────────────────────────────
|
|
@@ -219,8 +233,18 @@ export default function CodeContextPanel() {
|
|
|
219
233
|
codeSnippets,
|
|
220
234
|
removeSnippet,
|
|
221
235
|
clearSnippets,
|
|
236
|
+
openCodeRunner,
|
|
237
|
+
openSandbox,
|
|
238
|
+
openInfraLab,
|
|
239
|
+
openReactLab,
|
|
240
|
+
openNextLab,
|
|
241
|
+
removeQuestionFile,
|
|
242
|
+
renameContextFile,
|
|
222
243
|
} = useStore();
|
|
223
244
|
|
|
245
|
+
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
246
|
+
const [renameValue, setRenameValue] = useState("");
|
|
247
|
+
|
|
224
248
|
const [search, setSearch] = useState("");
|
|
225
249
|
const [selectedFiles, setSelectedFiles] = useState<string[]>(
|
|
226
250
|
currentQuestion?.codeContextFiles || [],
|
|
@@ -229,6 +253,19 @@ export default function CodeContextPanel() {
|
|
|
229
253
|
new Set(),
|
|
230
254
|
);
|
|
231
255
|
const [viewingFile, setViewingFile] = useState<string | null>(null);
|
|
256
|
+
const [notesOpen, setNotesOpen] = useState(false);
|
|
257
|
+
|
|
258
|
+
// Show a dot in the Notes button when there are saved notes for this context
|
|
259
|
+
const hasNotes = (() => {
|
|
260
|
+
try {
|
|
261
|
+
const raw = localStorage.getItem(notesKey(currentQuestion?.id));
|
|
262
|
+
if (!raw) return false;
|
|
263
|
+
const parsed = JSON.parse(raw);
|
|
264
|
+
return Array.isArray(parsed) && parsed.length > 0;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
232
269
|
|
|
233
270
|
useEffect(() => {
|
|
234
271
|
fetchAvailableFiles();
|
|
@@ -459,12 +496,689 @@ export default function CodeContextPanel() {
|
|
|
459
496
|
})}
|
|
460
497
|
</div>
|
|
461
498
|
|
|
499
|
+
{/* ── My Code section ─────────────────────────────────── */}
|
|
500
|
+
{currentQuestion && (
|
|
501
|
+
<div className="border-t border-slate-800 px-3 py-2">
|
|
502
|
+
<div className="flex items-center justify-between mb-1">
|
|
503
|
+
<div className="flex items-center gap-1">
|
|
504
|
+
<Terminal className="w-3 h-3 text-emerald-400/70" />
|
|
505
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600">
|
|
506
|
+
My Code (
|
|
507
|
+
{
|
|
508
|
+
(currentQuestion.contextFiles || []).filter(
|
|
509
|
+
(f) => f.origin === "user",
|
|
510
|
+
).length
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
</span>
|
|
514
|
+
</div>
|
|
515
|
+
<button
|
|
516
|
+
onClick={() => openCodeRunner()}
|
|
517
|
+
className="p-0.5 rounded text-slate-600 hover:text-emerald-400 hover:bg-slate-700 transition-colors"
|
|
518
|
+
title="Open Code Runner"
|
|
519
|
+
>
|
|
520
|
+
<Plus className="w-3.5 h-3.5" />
|
|
521
|
+
</button>
|
|
522
|
+
</div>
|
|
523
|
+
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
524
|
+
{(currentQuestion.contextFiles || [])
|
|
525
|
+
.filter((f) => f.origin === "user")
|
|
526
|
+
.map((cf) => (
|
|
527
|
+
<div
|
|
528
|
+
key={cf.id}
|
|
529
|
+
className="flex items-center gap-1 text-xs bg-emerald-500/10 border border-emerald-500/20 rounded px-1.5 py-1 group"
|
|
530
|
+
>
|
|
531
|
+
<span
|
|
532
|
+
className="text-emerald-400 font-medium truncate flex-1"
|
|
533
|
+
title={cf.label || cf.originalName}
|
|
534
|
+
>
|
|
535
|
+
{cf.label || cf.originalName}
|
|
536
|
+
</span>
|
|
537
|
+
<button
|
|
538
|
+
onClick={async () => {
|
|
539
|
+
const content = await fetch(
|
|
540
|
+
`/api/context-files/${cf.id}/content`,
|
|
541
|
+
)
|
|
542
|
+
.then((r) => r.json())
|
|
543
|
+
.then((d) => d.content);
|
|
544
|
+
openCodeRunner(
|
|
545
|
+
content,
|
|
546
|
+
cf.language ?? "typescript",
|
|
547
|
+
cf.id,
|
|
548
|
+
);
|
|
549
|
+
}}
|
|
550
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-emerald-400 transition-all"
|
|
551
|
+
title="Open in Code Runner"
|
|
552
|
+
>
|
|
553
|
+
<Play className="w-3 h-3" />
|
|
554
|
+
</button>
|
|
555
|
+
<button
|
|
556
|
+
onClick={() =>
|
|
557
|
+
removeQuestionFile(currentQuestion.id, cf.id)
|
|
558
|
+
}
|
|
559
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
|
|
560
|
+
title="Remove"
|
|
561
|
+
>
|
|
562
|
+
<Trash2 className="w-3 h-3" />
|
|
563
|
+
</button>
|
|
564
|
+
</div>
|
|
565
|
+
))}
|
|
566
|
+
{(currentQuestion.contextFiles || []).filter(
|
|
567
|
+
(f) => f.origin === "user",
|
|
568
|
+
).length === 0 && (
|
|
569
|
+
<p className="text-[10px] text-slate-700 italic">
|
|
570
|
+
Save code from the runner to see it here
|
|
571
|
+
</p>
|
|
572
|
+
)}
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
|
|
577
|
+
{/* ── AI Generated section ──────────────────────────── */}
|
|
578
|
+
{currentQuestion && (
|
|
579
|
+
<div className="border-t border-slate-800 px-3 py-2">
|
|
580
|
+
<div className="flex items-center gap-1 mb-1">
|
|
581
|
+
<Sparkles className="w-3 h-3 text-violet-400/70" />
|
|
582
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600">
|
|
583
|
+
AI Generated (
|
|
584
|
+
{
|
|
585
|
+
(currentQuestion.contextFiles || []).filter(
|
|
586
|
+
(f) => f.origin === "ai",
|
|
587
|
+
).length
|
|
588
|
+
}
|
|
589
|
+
)
|
|
590
|
+
</span>
|
|
591
|
+
</div>
|
|
592
|
+
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
593
|
+
{(currentQuestion.contextFiles || [])
|
|
594
|
+
.filter((f) => f.origin === "ai")
|
|
595
|
+
.map((cf) => (
|
|
596
|
+
<div
|
|
597
|
+
key={cf.id}
|
|
598
|
+
className="flex items-center gap-1 text-xs bg-violet-500/10 border border-violet-500/20 rounded px-1.5 py-1 group"
|
|
599
|
+
>
|
|
600
|
+
<span
|
|
601
|
+
className="text-violet-300 font-medium truncate flex-1"
|
|
602
|
+
title={cf.label || cf.originalName}
|
|
603
|
+
>
|
|
604
|
+
{cf.label || cf.originalName}
|
|
605
|
+
</span>
|
|
606
|
+
<button
|
|
607
|
+
onClick={async () => {
|
|
608
|
+
const content = await fetch(
|
|
609
|
+
`/api/context-files/${cf.id}/content`,
|
|
610
|
+
)
|
|
611
|
+
.then((r) => r.json())
|
|
612
|
+
.then((d) => d.content);
|
|
613
|
+
openCodeRunner(
|
|
614
|
+
content,
|
|
615
|
+
cf.language ?? "typescript",
|
|
616
|
+
cf.id,
|
|
617
|
+
);
|
|
618
|
+
}}
|
|
619
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-violet-400 transition-all"
|
|
620
|
+
title="Open in Code Runner"
|
|
621
|
+
>
|
|
622
|
+
<Play className="w-3 h-3" />
|
|
623
|
+
</button>
|
|
624
|
+
<button
|
|
625
|
+
onClick={() =>
|
|
626
|
+
removeQuestionFile(currentQuestion.id, cf.id)
|
|
627
|
+
}
|
|
628
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
|
|
629
|
+
title="Remove"
|
|
630
|
+
>
|
|
631
|
+
<Trash2 className="w-3 h-3" />
|
|
632
|
+
</button>
|
|
633
|
+
</div>
|
|
634
|
+
))}
|
|
635
|
+
{(currentQuestion.contextFiles || []).filter(
|
|
636
|
+
(f) => f.origin === "ai",
|
|
637
|
+
).length === 0 && (
|
|
638
|
+
<p className="text-[10px] text-slate-700 italic">
|
|
639
|
+
Save AI code blocks to see them here
|
|
640
|
+
</p>
|
|
641
|
+
)}
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
)}
|
|
645
|
+
|
|
646
|
+
{/* ── Sandboxes section ───────────────────────── */}
|
|
647
|
+
{currentQuestion && (
|
|
648
|
+
<div className="border-t border-slate-800 px-3 py-2">
|
|
649
|
+
<div className="flex items-center gap-1 mb-1">
|
|
650
|
+
<Server className="w-3 h-3 text-slate-500/70" />
|
|
651
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600">
|
|
652
|
+
Sandboxes (
|
|
653
|
+
{
|
|
654
|
+
(currentQuestion.contextFiles || []).filter(
|
|
655
|
+
(f) => f.origin === "sandbox",
|
|
656
|
+
).length
|
|
657
|
+
}
|
|
658
|
+
)
|
|
659
|
+
</span>
|
|
660
|
+
</div>
|
|
661
|
+
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
662
|
+
{(currentQuestion.contextFiles || [])
|
|
663
|
+
.filter((f) => f.origin === "sandbox")
|
|
664
|
+
.map((cf) => (
|
|
665
|
+
<div
|
|
666
|
+
key={cf.id}
|
|
667
|
+
className="flex items-center gap-1 text-xs bg-slate-500/10 border border-slate-500/20 rounded px-1.5 py-1 group"
|
|
668
|
+
>
|
|
669
|
+
{renamingId !== cf.id && (
|
|
670
|
+
<span
|
|
671
|
+
className="text-slate-300 font-medium truncate flex-1"
|
|
672
|
+
title={cf.label || cf.originalName}
|
|
673
|
+
>
|
|
674
|
+
{cf.label || cf.originalName}
|
|
675
|
+
</span>
|
|
676
|
+
)}
|
|
677
|
+
{renamingId === cf.id ? (
|
|
678
|
+
<>
|
|
679
|
+
<input
|
|
680
|
+
autoFocus
|
|
681
|
+
value={renameValue}
|
|
682
|
+
onChange={(e) => setRenameValue(e.target.value)}
|
|
683
|
+
onKeyDown={async (e) => {
|
|
684
|
+
if (e.key === "Enter") {
|
|
685
|
+
e.preventDefault();
|
|
686
|
+
if (renameValue.trim()) {
|
|
687
|
+
await renameContextFile(
|
|
688
|
+
currentQuestion.id,
|
|
689
|
+
cf.id,
|
|
690
|
+
renameValue.trim(),
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
setRenamingId(null);
|
|
694
|
+
} else if (e.key === "Escape") {
|
|
695
|
+
setRenamingId(null);
|
|
696
|
+
}
|
|
697
|
+
}}
|
|
698
|
+
className="w-28 bg-slate-900 border border-violet-600/50 rounded px-1.5 py-0.5 text-[11px] text-slate-200 outline-none focus:border-violet-500 shrink-0"
|
|
699
|
+
/>
|
|
700
|
+
<button
|
|
701
|
+
onClick={async () => {
|
|
702
|
+
if (renameValue.trim()) {
|
|
703
|
+
await renameContextFile(
|
|
704
|
+
currentQuestion.id,
|
|
705
|
+
cf.id,
|
|
706
|
+
renameValue.trim(),
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
setRenamingId(null);
|
|
710
|
+
}}
|
|
711
|
+
className="shrink-0 p-0.5 rounded text-violet-400 hover:bg-violet-600/20 transition-colors"
|
|
712
|
+
title="Confirm"
|
|
713
|
+
>
|
|
714
|
+
<Check className="w-3 h-3" />
|
|
715
|
+
</button>
|
|
716
|
+
<button
|
|
717
|
+
onClick={() => setRenamingId(null)}
|
|
718
|
+
className="shrink-0 p-0.5 rounded text-slate-500 hover:text-slate-300 transition-colors"
|
|
719
|
+
title="Cancel"
|
|
720
|
+
>
|
|
721
|
+
<X className="w-3 h-3" />
|
|
722
|
+
</button>
|
|
723
|
+
</>
|
|
724
|
+
) : (
|
|
725
|
+
<>
|
|
726
|
+
<button
|
|
727
|
+
onClick={() => {
|
|
728
|
+
setRenamingId(cf.id);
|
|
729
|
+
setRenameValue(cf.label || cf.originalName);
|
|
730
|
+
}}
|
|
731
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-violet-300 transition-all"
|
|
732
|
+
title="Rename"
|
|
733
|
+
>
|
|
734
|
+
<Pencil className="w-3 h-3" />
|
|
735
|
+
</button>
|
|
736
|
+
<button
|
|
737
|
+
onClick={async () => {
|
|
738
|
+
try {
|
|
739
|
+
const raw = await fetch(
|
|
740
|
+
`/api/context-files/${cf.id}/content`,
|
|
741
|
+
)
|
|
742
|
+
.then((r) => r.json())
|
|
743
|
+
.then((d) => d.content as string);
|
|
744
|
+
const parsed = JSON.parse(raw) as {
|
|
745
|
+
serverCode: string;
|
|
746
|
+
serverLang: string;
|
|
747
|
+
clientCode: string;
|
|
748
|
+
clientLang: string;
|
|
749
|
+
clientType?: "script" | "react" | "nextjs";
|
|
750
|
+
reactFiles?: Record<string, string>;
|
|
751
|
+
reactActiveFile?: string;
|
|
752
|
+
};
|
|
753
|
+
openSandbox(
|
|
754
|
+
parsed.serverCode,
|
|
755
|
+
parsed.serverLang,
|
|
756
|
+
parsed.clientCode,
|
|
757
|
+
parsed.clientLang,
|
|
758
|
+
cf.id,
|
|
759
|
+
parsed.clientType
|
|
760
|
+
? {
|
|
761
|
+
clientType: parsed.clientType,
|
|
762
|
+
reactFiles: parsed.reactFiles,
|
|
763
|
+
reactActiveFile: parsed.reactActiveFile,
|
|
764
|
+
}
|
|
765
|
+
: undefined,
|
|
766
|
+
);
|
|
767
|
+
} catch {
|
|
768
|
+
/* malformed — ignore */
|
|
769
|
+
}
|
|
770
|
+
}}
|
|
771
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-violet-400 transition-all"
|
|
772
|
+
title="Open in Sandbox"
|
|
773
|
+
>
|
|
774
|
+
<Play className="w-3 h-3" />
|
|
775
|
+
</button>
|
|
776
|
+
<button
|
|
777
|
+
onClick={() =>
|
|
778
|
+
removeQuestionFile(currentQuestion.id, cf.id)
|
|
779
|
+
}
|
|
780
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
|
|
781
|
+
title="Remove"
|
|
782
|
+
>
|
|
783
|
+
<Trash2 className="w-3 h-3" />
|
|
784
|
+
</button>
|
|
785
|
+
</>
|
|
786
|
+
)}
|
|
787
|
+
</div>
|
|
788
|
+
))}
|
|
789
|
+
{(currentQuestion.contextFiles || []).filter(
|
|
790
|
+
(f) => f.origin === "sandbox",
|
|
791
|
+
).length === 0 && (
|
|
792
|
+
<p className="text-[10px] text-slate-700 italic">
|
|
793
|
+
Save a sandbox to see it here
|
|
794
|
+
</p>
|
|
795
|
+
)}
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
)}
|
|
799
|
+
|
|
800
|
+
{currentQuestion && (
|
|
801
|
+
<div className="border-t border-slate-800 px-3 py-2">
|
|
802
|
+
<div className="flex items-center justify-between mb-1">
|
|
803
|
+
<div className="flex items-center gap-1">
|
|
804
|
+
<Globe className="w-3 h-3 text-cyan-400/70" />
|
|
805
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600">
|
|
806
|
+
Infra Labs (
|
|
807
|
+
{
|
|
808
|
+
(currentQuestion.contextFiles || []).filter(
|
|
809
|
+
(f) => f.origin === "infra",
|
|
810
|
+
).length
|
|
811
|
+
}
|
|
812
|
+
)
|
|
813
|
+
</span>
|
|
814
|
+
</div>
|
|
815
|
+
<button
|
|
816
|
+
onClick={() => openInfraLab()}
|
|
817
|
+
className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
|
|
818
|
+
title="Open Infrastructure Lab"
|
|
819
|
+
>
|
|
820
|
+
<Plus className="w-3.5 h-3.5" />
|
|
821
|
+
</button>
|
|
822
|
+
</div>
|
|
823
|
+
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
824
|
+
{(currentQuestion.contextFiles || [])
|
|
825
|
+
.filter((f) => f.origin === "infra")
|
|
826
|
+
.map((cf) => (
|
|
827
|
+
<div
|
|
828
|
+
key={cf.id}
|
|
829
|
+
className="flex items-center gap-1 text-xs bg-cyan-500/10 border border-cyan-500/20 rounded px-1.5 py-1 group"
|
|
830
|
+
>
|
|
831
|
+
{renamingId !== cf.id && (
|
|
832
|
+
<span
|
|
833
|
+
className="text-cyan-200 font-medium truncate flex-1"
|
|
834
|
+
title={cf.label || cf.originalName}
|
|
835
|
+
>
|
|
836
|
+
{cf.label || cf.originalName}
|
|
837
|
+
</span>
|
|
838
|
+
)}
|
|
839
|
+
{renamingId === cf.id ? (
|
|
840
|
+
<>
|
|
841
|
+
<input
|
|
842
|
+
autoFocus
|
|
843
|
+
value={renameValue}
|
|
844
|
+
onChange={(e) => setRenameValue(e.target.value)}
|
|
845
|
+
onKeyDown={async (e) => {
|
|
846
|
+
if (e.key === "Enter") {
|
|
847
|
+
e.preventDefault();
|
|
848
|
+
if (renameValue.trim()) {
|
|
849
|
+
await renameContextFile(
|
|
850
|
+
currentQuestion.id,
|
|
851
|
+
cf.id,
|
|
852
|
+
renameValue.trim(),
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
setRenamingId(null);
|
|
856
|
+
} else if (e.key === "Escape") {
|
|
857
|
+
setRenamingId(null);
|
|
858
|
+
}
|
|
859
|
+
}}
|
|
860
|
+
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"
|
|
861
|
+
/>
|
|
862
|
+
<button
|
|
863
|
+
onClick={async () => {
|
|
864
|
+
if (renameValue.trim()) {
|
|
865
|
+
await renameContextFile(
|
|
866
|
+
currentQuestion.id,
|
|
867
|
+
cf.id,
|
|
868
|
+
renameValue.trim(),
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
setRenamingId(null);
|
|
872
|
+
}}
|
|
873
|
+
className="shrink-0 p-0.5 rounded text-cyan-400 hover:bg-cyan-600/20 transition-colors"
|
|
874
|
+
title="Confirm"
|
|
875
|
+
>
|
|
876
|
+
<Check className="w-3 h-3" />
|
|
877
|
+
</button>
|
|
878
|
+
<button
|
|
879
|
+
onClick={() => setRenamingId(null)}
|
|
880
|
+
className="shrink-0 p-0.5 rounded text-slate-500 hover:text-slate-300 transition-colors"
|
|
881
|
+
title="Cancel"
|
|
882
|
+
>
|
|
883
|
+
<X className="w-3 h-3" />
|
|
884
|
+
</button>
|
|
885
|
+
</>
|
|
886
|
+
) : (
|
|
887
|
+
<>
|
|
888
|
+
<button
|
|
889
|
+
onClick={() => {
|
|
890
|
+
setRenamingId(cf.id);
|
|
891
|
+
setRenameValue(cf.label || cf.originalName);
|
|
892
|
+
}}
|
|
893
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-300 transition-all"
|
|
894
|
+
title="Rename"
|
|
895
|
+
>
|
|
896
|
+
<Pencil className="w-3 h-3" />
|
|
897
|
+
</button>
|
|
898
|
+
<button
|
|
899
|
+
onClick={async () => {
|
|
900
|
+
const raw = await fetch(
|
|
901
|
+
`/api/context-files/${cf.id}/content`,
|
|
902
|
+
)
|
|
903
|
+
.then((r) => r.json())
|
|
904
|
+
.then((d) => d.content as string);
|
|
905
|
+
const parsed = parseInfraLabWorkspace(raw);
|
|
906
|
+
if (parsed) openInfraLab(parsed, cf.id);
|
|
907
|
+
}}
|
|
908
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
|
|
909
|
+
title="Open in Infrastructure Lab"
|
|
910
|
+
>
|
|
911
|
+
<Play className="w-3 h-3" />
|
|
912
|
+
</button>
|
|
913
|
+
<button
|
|
914
|
+
onClick={() =>
|
|
915
|
+
removeQuestionFile(currentQuestion.id, cf.id)
|
|
916
|
+
}
|
|
917
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
|
|
918
|
+
title="Remove"
|
|
919
|
+
>
|
|
920
|
+
<Trash2 className="w-3 h-3" />
|
|
921
|
+
</button>
|
|
922
|
+
</>
|
|
923
|
+
)}
|
|
924
|
+
</div>
|
|
925
|
+
))}
|
|
926
|
+
{(currentQuestion.contextFiles || []).filter(
|
|
927
|
+
(f) => f.origin === "infra",
|
|
928
|
+
).length === 0 && (
|
|
929
|
+
<p className="text-[10px] text-slate-700 italic">
|
|
930
|
+
Save an infra lab to reopen it here
|
|
931
|
+
</p>
|
|
932
|
+
)}
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
)}
|
|
936
|
+
|
|
937
|
+
{/* ── React Labs section ───────────────────── */}
|
|
938
|
+
{currentQuestion && (
|
|
939
|
+
<div className="border-t border-slate-800 px-3 py-2">
|
|
940
|
+
<div className="flex items-center justify-between mb-1">
|
|
941
|
+
<div className="flex items-center gap-1">
|
|
942
|
+
<Atom className="w-3 h-3 text-cyan-400/70" />
|
|
943
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600">
|
|
944
|
+
React Labs (
|
|
945
|
+
{
|
|
946
|
+
(currentQuestion.contextFiles || []).filter(
|
|
947
|
+
(f) => f.origin === "react",
|
|
948
|
+
).length
|
|
949
|
+
}
|
|
950
|
+
)
|
|
951
|
+
</span>
|
|
952
|
+
</div>
|
|
953
|
+
<button
|
|
954
|
+
onClick={() => openReactLab()}
|
|
955
|
+
className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
|
|
956
|
+
title="Open React Lab"
|
|
957
|
+
>
|
|
958
|
+
<Plus className="w-3.5 h-3.5" />
|
|
959
|
+
</button>
|
|
960
|
+
</div>
|
|
961
|
+
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
962
|
+
{(currentQuestion.contextFiles || [])
|
|
963
|
+
.filter((f) => f.origin === "react")
|
|
964
|
+
.map((cf) => (
|
|
965
|
+
<div
|
|
966
|
+
key={cf.id}
|
|
967
|
+
className="flex items-center gap-1 text-xs bg-cyan-500/10 border border-cyan-500/20 rounded px-1.5 py-1 group"
|
|
968
|
+
>
|
|
969
|
+
<span
|
|
970
|
+
className="text-cyan-200 font-medium truncate flex-1"
|
|
971
|
+
title={cf.label || cf.originalName}
|
|
972
|
+
>
|
|
973
|
+
{cf.label || cf.originalName}
|
|
974
|
+
</span>
|
|
975
|
+
<button
|
|
976
|
+
onClick={async () => {
|
|
977
|
+
try {
|
|
978
|
+
const raw = await fetch(
|
|
979
|
+
`/api/context-files/${cf.id}/content`,
|
|
980
|
+
)
|
|
981
|
+
.then((r) => r.json())
|
|
982
|
+
.then((d) => d.content as string);
|
|
983
|
+
// Try new extended sandbox format first
|
|
984
|
+
const ext = JSON.parse(raw) as {
|
|
985
|
+
clientType?: string;
|
|
986
|
+
reactFiles?: Record<string, string>;
|
|
987
|
+
reactActiveFile?: string;
|
|
988
|
+
serverCode?: string;
|
|
989
|
+
serverLang?: string;
|
|
990
|
+
};
|
|
991
|
+
if (ext?.clientType === "react" && ext.reactFiles) {
|
|
992
|
+
openReactLab(
|
|
993
|
+
{
|
|
994
|
+
version: 1,
|
|
995
|
+
type: "react",
|
|
996
|
+
label: cf.label || "React Lab",
|
|
997
|
+
activeFile:
|
|
998
|
+
ext.reactActiveFile ??
|
|
999
|
+
Object.keys(ext.reactFiles)[0] ??
|
|
1000
|
+
"App.tsx",
|
|
1001
|
+
files: ext.reactFiles,
|
|
1002
|
+
},
|
|
1003
|
+
cf.id,
|
|
1004
|
+
ext.serverCode,
|
|
1005
|
+
ext.serverLang,
|
|
1006
|
+
);
|
|
1007
|
+
} else {
|
|
1008
|
+
// Fall back to old FrontendLabWorkspace format
|
|
1009
|
+
const ws = parseFrontendLabWorkspace(raw);
|
|
1010
|
+
if (ws) openReactLab(ws, cf.id);
|
|
1011
|
+
}
|
|
1012
|
+
} catch {
|
|
1013
|
+
/* ignore */
|
|
1014
|
+
}
|
|
1015
|
+
}}
|
|
1016
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
|
|
1017
|
+
title="Open in React Lab"
|
|
1018
|
+
>
|
|
1019
|
+
<Play className="w-3 h-3" />
|
|
1020
|
+
</button>
|
|
1021
|
+
<button
|
|
1022
|
+
onClick={() =>
|
|
1023
|
+
removeQuestionFile(currentQuestion.id, cf.id)
|
|
1024
|
+
}
|
|
1025
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
|
|
1026
|
+
title="Remove"
|
|
1027
|
+
>
|
|
1028
|
+
<Trash2 className="w-3 h-3" />
|
|
1029
|
+
</button>
|
|
1030
|
+
</div>
|
|
1031
|
+
))}
|
|
1032
|
+
{(currentQuestion.contextFiles || []).filter(
|
|
1033
|
+
(f) => f.origin === "react",
|
|
1034
|
+
).length === 0 && (
|
|
1035
|
+
<p className="text-[10px] text-slate-700 italic">
|
|
1036
|
+
Save a React lab to reopen it here
|
|
1037
|
+
</p>
|
|
1038
|
+
)}
|
|
1039
|
+
</div>
|
|
1040
|
+
</div>
|
|
1041
|
+
)}
|
|
1042
|
+
|
|
1043
|
+
{/* ── Next.js Labs section ──────────────────── */}
|
|
1044
|
+
{currentQuestion && (
|
|
1045
|
+
<div className="border-t border-slate-800 px-3 py-2">
|
|
1046
|
+
<div className="flex items-center justify-between mb-1">
|
|
1047
|
+
<div className="flex items-center gap-1">
|
|
1048
|
+
<Layout className="w-3 h-3 text-violet-400/70" />
|
|
1049
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600">
|
|
1050
|
+
Next.js Labs (
|
|
1051
|
+
{
|
|
1052
|
+
(currentQuestion.contextFiles || []).filter(
|
|
1053
|
+
(f) => f.origin === "nextjs",
|
|
1054
|
+
).length
|
|
1055
|
+
}
|
|
1056
|
+
)
|
|
1057
|
+
</span>
|
|
1058
|
+
</div>
|
|
1059
|
+
<button
|
|
1060
|
+
onClick={() => openNextLab()}
|
|
1061
|
+
className="p-0.5 rounded text-slate-600 hover:text-violet-400 hover:bg-slate-700 transition-colors"
|
|
1062
|
+
title="Open Next.js Lab"
|
|
1063
|
+
>
|
|
1064
|
+
<Plus className="w-3.5 h-3.5" />
|
|
1065
|
+
</button>
|
|
1066
|
+
</div>
|
|
1067
|
+
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
1068
|
+
{(currentQuestion.contextFiles || [])
|
|
1069
|
+
.filter((f) => f.origin === "nextjs")
|
|
1070
|
+
.map((cf) => (
|
|
1071
|
+
<div
|
|
1072
|
+
key={cf.id}
|
|
1073
|
+
className="flex items-center gap-1 text-xs bg-violet-500/10 border border-violet-500/20 rounded px-1.5 py-1 group"
|
|
1074
|
+
>
|
|
1075
|
+
<span
|
|
1076
|
+
className="text-violet-200 font-medium truncate flex-1"
|
|
1077
|
+
title={cf.label || cf.originalName}
|
|
1078
|
+
>
|
|
1079
|
+
{cf.label || cf.originalName}
|
|
1080
|
+
</span>
|
|
1081
|
+
<button
|
|
1082
|
+
onClick={async () => {
|
|
1083
|
+
try {
|
|
1084
|
+
const raw = await fetch(
|
|
1085
|
+
`/api/context-files/${cf.id}/content`,
|
|
1086
|
+
)
|
|
1087
|
+
.then((r) => r.json())
|
|
1088
|
+
.then((d) => d.content as string);
|
|
1089
|
+
const ext = JSON.parse(raw) as {
|
|
1090
|
+
clientType?: string;
|
|
1091
|
+
reactFiles?: Record<string, string>;
|
|
1092
|
+
reactActiveFile?: string;
|
|
1093
|
+
serverCode?: string;
|
|
1094
|
+
serverLang?: string;
|
|
1095
|
+
};
|
|
1096
|
+
if (ext?.clientType === "nextjs" && ext.reactFiles) {
|
|
1097
|
+
openNextLab(
|
|
1098
|
+
{
|
|
1099
|
+
version: 1,
|
|
1100
|
+
type: "nextjs",
|
|
1101
|
+
label: cf.label || "Next.js Lab",
|
|
1102
|
+
activeFile:
|
|
1103
|
+
ext.reactActiveFile ??
|
|
1104
|
+
Object.keys(ext.reactFiles)[0] ??
|
|
1105
|
+
"app/page.tsx",
|
|
1106
|
+
files: ext.reactFiles,
|
|
1107
|
+
},
|
|
1108
|
+
cf.id,
|
|
1109
|
+
ext.serverCode,
|
|
1110
|
+
ext.serverLang,
|
|
1111
|
+
);
|
|
1112
|
+
} else {
|
|
1113
|
+
const ws = parseFrontendLabWorkspace(raw);
|
|
1114
|
+
if (ws) openNextLab(ws, cf.id);
|
|
1115
|
+
}
|
|
1116
|
+
} catch {
|
|
1117
|
+
/* ignore */
|
|
1118
|
+
}
|
|
1119
|
+
}}
|
|
1120
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-violet-400 transition-all"
|
|
1121
|
+
title="Open in Next.js Lab"
|
|
1122
|
+
>
|
|
1123
|
+
<Play className="w-3 h-3" />
|
|
1124
|
+
</button>
|
|
1125
|
+
<button
|
|
1126
|
+
onClick={() =>
|
|
1127
|
+
removeQuestionFile(currentQuestion.id, cf.id)
|
|
1128
|
+
}
|
|
1129
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
|
|
1130
|
+
title="Remove"
|
|
1131
|
+
>
|
|
1132
|
+
<Trash2 className="w-3 h-3" />
|
|
1133
|
+
</button>
|
|
1134
|
+
</div>
|
|
1135
|
+
))}
|
|
1136
|
+
{(currentQuestion.contextFiles || []).filter(
|
|
1137
|
+
(f) => f.origin === "nextjs",
|
|
1138
|
+
).length === 0 && (
|
|
1139
|
+
<p className="text-[10px] text-slate-700 italic">
|
|
1140
|
+
Save a Next.js lab to reopen it here
|
|
1141
|
+
</p>
|
|
1142
|
+
)}
|
|
1143
|
+
</div>
|
|
1144
|
+
</div>
|
|
1145
|
+
)}
|
|
1146
|
+
|
|
1147
|
+
{/* ── Notes section ────────────────────────────────────── */}
|
|
1148
|
+
<div className="border-t border-slate-800 px-3 py-2">
|
|
1149
|
+
<button
|
|
1150
|
+
onClick={() => setNotesOpen((v) => !v)}
|
|
1151
|
+
className="w-full flex items-center gap-2 group"
|
|
1152
|
+
>
|
|
1153
|
+
<NotebookPen className="w-3 h-3 text-amber-400/70 shrink-0" />
|
|
1154
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600 flex-1 text-left">
|
|
1155
|
+
Notes
|
|
1156
|
+
</span>
|
|
1157
|
+
{hasNotes && (
|
|
1158
|
+
<span
|
|
1159
|
+
className="w-1.5 h-1.5 rounded-full bg-amber-400/70 shrink-0"
|
|
1160
|
+
title="Has notes"
|
|
1161
|
+
/>
|
|
1162
|
+
)}
|
|
1163
|
+
<span className="text-[10px] text-slate-700 group-hover:text-slate-500 transition-colors">
|
|
1164
|
+
{notesOpen ? "close" : "open"}
|
|
1165
|
+
</span>
|
|
1166
|
+
</button>
|
|
1167
|
+
</div>
|
|
1168
|
+
|
|
462
1169
|
{viewingFile && (
|
|
463
1170
|
<FileViewerModal
|
|
464
1171
|
filePath={viewingFile}
|
|
465
1172
|
onClose={() => setViewingFile(null)}
|
|
466
1173
|
/>
|
|
467
1174
|
)}
|
|
1175
|
+
|
|
1176
|
+
{notesOpen && (
|
|
1177
|
+
<NotesModal
|
|
1178
|
+
questionId={currentQuestion?.id}
|
|
1179
|
+
onClose={() => setNotesOpen(false)}
|
|
1180
|
+
/>
|
|
1181
|
+
)}
|
|
468
1182
|
</div>
|
|
469
1183
|
);
|
|
470
1184
|
}
|