create-interview-cockpit 0.5.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 (29) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +321 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +419 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1601 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +477 -0
  23. package/template/client/src/store.ts +219 -6
  24. package/template/client/src/types.ts +35 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/server/src/google-drive.ts +37 -3
  27. package/template/server/src/index.ts +693 -52
  28. package/template/server/src/infra-runner.ts +1104 -0
  29. package/template/server/src/storage.ts +13 -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,
@@ -22,6 +25,10 @@ import {
22
25
  Trash2,
23
26
  Server,
24
27
  Pencil,
28
+ NotebookPen,
29
+ Globe,
30
+ Atom,
31
+ Layout,
25
32
  } from "lucide-react";
26
33
 
27
34
  // ─── Tree data structure ─────────────────────────────────
@@ -228,6 +235,9 @@ export default function CodeContextPanel() {
228
235
  clearSnippets,
229
236
  openCodeRunner,
230
237
  openSandbox,
238
+ openInfraLab,
239
+ openReactLab,
240
+ openNextLab,
231
241
  removeQuestionFile,
232
242
  renameContextFile,
233
243
  } = useStore();
@@ -243,6 +253,19 @@ export default function CodeContextPanel() {
243
253
  new Set(),
244
254
  );
245
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
+ })();
246
269
 
247
270
  useEffect(() => {
248
271
  fetchAvailableFiles();
@@ -518,7 +541,11 @@ export default function CodeContextPanel() {
518
541
  )
519
542
  .then((r) => r.json())
520
543
  .then((d) => d.content);
521
- openCodeRunner(content, cf.language ?? "typescript");
544
+ openCodeRunner(
545
+ content,
546
+ cf.language ?? "typescript",
547
+ cf.id,
548
+ );
522
549
  }}
523
550
  className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-emerald-400 transition-all"
524
551
  title="Open in Code Runner"
@@ -583,7 +610,11 @@ export default function CodeContextPanel() {
583
610
  )
584
611
  .then((r) => r.json())
585
612
  .then((d) => d.content);
586
- openCodeRunner(content, cf.language ?? "typescript");
613
+ openCodeRunner(
614
+ content,
615
+ cf.language ?? "typescript",
616
+ cf.id,
617
+ );
587
618
  }}
588
619
  className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-violet-400 transition-all"
589
620
  title="Open in Code Runner"
@@ -715,6 +746,9 @@ export default function CodeContextPanel() {
715
746
  serverLang: string;
716
747
  clientCode: string;
717
748
  clientLang: string;
749
+ clientType?: "script" | "react" | "nextjs";
750
+ reactFiles?: Record<string, string>;
751
+ reactActiveFile?: string;
718
752
  };
719
753
  openSandbox(
720
754
  parsed.serverCode,
@@ -722,6 +756,13 @@ export default function CodeContextPanel() {
722
756
  parsed.clientCode,
723
757
  parsed.clientLang,
724
758
  cf.id,
759
+ parsed.clientType
760
+ ? {
761
+ clientType: parsed.clientType,
762
+ reactFiles: parsed.reactFiles,
763
+ reactActiveFile: parsed.reactActiveFile,
764
+ }
765
+ : undefined,
725
766
  );
726
767
  } catch {
727
768
  /* malformed — ignore */
@@ -756,12 +797,388 @@ export default function CodeContextPanel() {
756
797
  </div>
757
798
  )}
758
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
+
759
1169
  {viewingFile && (
760
1170
  <FileViewerModal
761
1171
  filePath={viewingFile}
762
1172
  onClose={() => setViewingFile(null)}
763
1173
  />
764
1174
  )}
1175
+
1176
+ {notesOpen && (
1177
+ <NotesModal
1178
+ questionId={currentQuestion?.id}
1179
+ onClose={() => setNotesOpen(false)}
1180
+ />
1181
+ )}
765
1182
  </div>
766
1183
  );
767
1184
  }