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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +753 -1
  3. package/template/client/package.json +4 -0
  4. package/template/client/src/App.tsx +20 -0
  5. package/template/client/src/api.ts +455 -3
  6. package/template/client/src/components/AiSettingsModal.tsx +855 -248
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +132 -27
  9. package/template/client/src/components/ChatView.tsx +365 -123
  10. package/template/client/src/components/CodeContextPanel.tsx +714 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
  13. package/template/client/src/components/DocRefModal.tsx +551 -0
  14. package/template/client/src/components/FileAttachments.tsx +128 -12
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  18. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  19. package/template/client/src/components/MarkdownRenderer.tsx +219 -2
  20. package/template/client/src/components/NotesModal.tsx +977 -0
  21. package/template/client/src/components/PlotEmbed.tsx +173 -0
  22. package/template/client/src/components/Sidebar.tsx +397 -127
  23. package/template/client/src/components/TextAnnotator.tsx +8 -15
  24. package/template/client/src/components/VizCraftEmbed.tsx +412 -25
  25. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  26. package/template/client/src/infraLab.ts +124 -0
  27. package/template/client/src/reactLab.ts +477 -0
  28. package/template/client/src/store.ts +416 -2
  29. package/template/client/src/types.ts +41 -1
  30. package/template/client/tsconfig.tsbuildinfo +1 -1
  31. package/template/cockpit.json +1 -1
  32. package/template/package.json +1 -1
  33. package/template/server/src/google-drive.ts +144 -2
  34. package/template/server/src/index.ts +1890 -188
  35. package/template/server/src/infra-runner.ts +1104 -0
  36. 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
  }