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
@@ -9,9 +9,15 @@ import {
9
9
  Loader2,
10
10
  Plus,
11
11
  Check,
12
+ MessageCircle,
13
+ Send,
14
+ Play,
12
15
  } from "lucide-react";
13
16
  import { useStore } from "../store";
14
17
  import type { CodeSnippet } from "../types";
18
+ import CodeLineAnnotationPopup, {
19
+ type CodeAnnotation,
20
+ } from "./CodeLineAnnotationPopup";
15
21
 
16
22
  interface Props {
17
23
  filePath: string;
@@ -70,7 +76,18 @@ const DEFAULT_H = 520;
70
76
  type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
71
77
 
72
78
  export default function FileViewerModal({ filePath, onClose }: Props) {
73
- const { addSnippet } = useStore();
79
+ const {
80
+ addSnippet,
81
+ currentQuestion,
82
+ selectedTopicId,
83
+ codeSnippets,
84
+ livePreferenceSuffix,
85
+ inlineCodeSnippets,
86
+ openCodeRunner,
87
+ } = useStore();
88
+ // stable ref so callbacks can read the latest question id without re-creating
89
+ const currentQuestionIdRef = useRef<string | undefined>(undefined);
90
+ currentQuestionIdRef.current = currentQuestion?.id;
74
91
  const [content, setContent] = useState<string | null>(null);
75
92
  const [loading, setLoading] = useState(true);
76
93
  const [error, setError] = useState<string | null>(null);
@@ -79,6 +96,19 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
79
96
  const [addedFeedback, setAddedFeedback] = useState(false);
80
97
  const lastClickedLineRef = useRef<number | null>(null);
81
98
 
99
+ // ── Code chat ─────────────────────────────────────────────
100
+ const [chatPanelOpen, setChatPanelOpen] = useState(false);
101
+ const [chatContextLines, setChatContextLines] = useState<Set<number>>(
102
+ new Set(),
103
+ );
104
+ const [chatInput, setChatInput] = useState("");
105
+ const [chatLoading, setChatLoading] = useState(false);
106
+ const [codeAnnotations, setCodeAnnotations] = useState<CodeAnnotation[]>([]);
107
+ const [openAnnotationId, setOpenAnnotationId] = useState<string | null>(null);
108
+ const chatInputRef = useRef<HTMLTextAreaElement>(null);
109
+ const codeAnnotationsRef = useRef<CodeAnnotation[]>([]);
110
+ const chatPanelOpenRef = useRef(false);
111
+
82
112
  // Position & size (pre-maximise)
83
113
  const [pos, setPos] = useState(() => ({
84
114
  x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
@@ -104,15 +134,55 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
104
134
  const savedPos = useRef(pos);
105
135
  const savedSize = useRef(size);
106
136
 
107
- const fileName = filePath.split("/").pop() ?? filePath;
137
+ const isInline = filePath.startsWith("inline:");
138
+ const inlineEntry = isInline ? inlineCodeSnippets[filePath] : undefined;
139
+ const fileName = isInline
140
+ ? (inlineEntry?.label ?? filePath.slice("inline:".length))
141
+ : (filePath.split("/").pop() ?? filePath);
108
142
 
109
- // Fetch file content
143
+ // Fetch file content + load persisted annotations
110
144
  useEffect(() => {
111
145
  setLoading(true);
112
146
  setError(null);
113
147
  setSelectedLines(new Set());
114
148
  lastClickedLineRef.current = null;
115
- fetch(`/api/code-context/file?path=${encodeURIComponent(filePath)}`)
149
+ setChatContextLines(new Set());
150
+ setChatInput("");
151
+ setCodeAnnotations([]);
152
+ setOpenAnnotationId(null);
153
+
154
+ // Inline AI-written code: read from the in-memory registry — no server fetch
155
+ if (filePath.startsWith("inline:")) {
156
+ const entry = useStore.getState().inlineCodeSnippets[filePath];
157
+ if (entry) {
158
+ setContent(entry.content);
159
+ } else {
160
+ setError("Inline snippet not available yet.");
161
+ }
162
+ setLoading(false);
163
+
164
+ // Still load persisted annotations for inline blocks
165
+ const qId = currentQuestionIdRef.current;
166
+ if (qId) {
167
+ fetch(
168
+ `/api/questions/${qId}/code-annotations?filePath=${encodeURIComponent(filePath)}`,
169
+ )
170
+ .then((r) => r.json())
171
+ .then((d) => {
172
+ if (Array.isArray(d.annotations) && d.annotations.length > 0) {
173
+ setCodeAnnotations(d.annotations);
174
+ }
175
+ })
176
+ .catch(() => {
177
+ /* non-fatal */
178
+ });
179
+ }
180
+ return;
181
+ }
182
+
183
+ const fetchFile = fetch(
184
+ `/api/code-context/file?path=${encodeURIComponent(filePath)}`,
185
+ )
116
186
  .then((r) => r.json())
117
187
  .then((d) => {
118
188
  if (d.error) setError(d.error);
@@ -120,22 +190,80 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
120
190
  })
121
191
  .catch(() => setError("Failed to load file."))
122
192
  .finally(() => setLoading(false));
193
+
194
+ // load persisted annotations for this file if we have a question context
195
+ const qId = currentQuestionIdRef.current;
196
+ if (qId) {
197
+ fetch(
198
+ `/api/questions/${qId}/code-annotations?filePath=${encodeURIComponent(filePath)}`,
199
+ )
200
+ .then((r) => r.json())
201
+ .then((d) => {
202
+ if (Array.isArray(d.annotations) && d.annotations.length > 0) {
203
+ setCodeAnnotations(d.annotations);
204
+ }
205
+ })
206
+ .catch(() => {
207
+ /* non-fatal */
208
+ });
209
+ }
210
+
211
+ return () => {
212
+ void fetchFile;
213
+ };
123
214
  }, [filePath]);
124
215
 
125
216
  const handleLineClick = useCallback(
126
217
  (e: React.MouseEvent, lineNumber: number) => {
127
- // Don't toggle if user was drag-selecting text
128
218
  if (window.getSelection()?.toString()) return;
219
+
220
+ // 1. Chat panel open → always add/remove from chat context (even annotated lines)
221
+ if (chatPanelOpenRef.current) {
222
+ setChatContextLines((prev) => {
223
+ const next = new Set(prev);
224
+ if (e.shiftKey && lastClickedLineRef.current !== null) {
225
+ const lo = Math.min(lastClickedLineRef.current, lineNumber);
226
+ const hi = Math.max(lastClickedLineRef.current, lineNumber);
227
+ for (let i = lo; i <= hi; i++) next.add(i);
228
+ } else {
229
+ if (next.has(lineNumber)) next.delete(lineNumber);
230
+ else next.add(lineNumber);
231
+ }
232
+ return next;
233
+ });
234
+ lastClickedLineRef.current = lineNumber;
235
+ return;
236
+ }
237
+
238
+ // 2. Shift+click → extend snippet selection on any line, including annotated ones
239
+ if (e.shiftKey && lastClickedLineRef.current !== null) {
240
+ setSelectedLines((prev) => {
241
+ const next = new Set(prev);
242
+ const lo = Math.min(lastClickedLineRef.current!, lineNumber);
243
+ const hi = Math.max(lastClickedLineRef.current!, lineNumber);
244
+ for (let i = lo; i <= hi; i++) next.add(i);
245
+ return next;
246
+ });
247
+ lastClickedLineRef.current = lineNumber;
248
+ return;
249
+ }
250
+
251
+ // 3. Normal click on annotated line → toggle popup
252
+ const annotation = codeAnnotationsRef.current.find(
253
+ (a) => a.lineNumber === lineNumber,
254
+ );
255
+ if (annotation) {
256
+ setOpenAnnotationId((prev) =>
257
+ prev === annotation.id ? null : annotation.id,
258
+ );
259
+ return;
260
+ }
261
+
262
+ // 4. Default: toggle snippet selection
129
263
  setSelectedLines((prev) => {
130
264
  const next = new Set(prev);
131
- if (e.shiftKey && lastClickedLineRef.current !== null) {
132
- const lo = Math.min(lastClickedLineRef.current, lineNumber);
133
- const hi = Math.max(lastClickedLineRef.current, lineNumber);
134
- for (let i = lo; i <= hi; i++) next.add(i);
135
- } else {
136
- if (next.has(lineNumber)) next.delete(lineNumber);
137
- else next.add(lineNumber);
138
- }
265
+ if (next.has(lineNumber)) next.delete(lineNumber);
266
+ else next.add(lineNumber);
139
267
  return next;
140
268
  });
141
269
  lastClickedLineRef.current = lineNumber;
@@ -163,6 +291,84 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
163
291
  setTimeout(() => setAddedFeedback(false), 1500);
164
292
  }, [content, selectedLines, filePath, fileName, addSnippet]);
165
293
 
294
+ const handleChatSend = useCallback(async () => {
295
+ if (
296
+ !chatInput.trim() ||
297
+ chatLoading ||
298
+ !content ||
299
+ chatContextLines.size === 0
300
+ )
301
+ return;
302
+ const lines = content.split("\n");
303
+ const sortedNums = [...chatContextLines].sort((a, b) => a - b);
304
+ const selectedCode = sortedNums
305
+ .map((n) => `${n}: ${lines[n - 1] ?? ""}`)
306
+ .join("\n");
307
+ const firstLine = sortedNums[0];
308
+ const firstLineContent = lines[firstLine - 1] ?? "";
309
+ const promptText = chatInput.trim();
310
+ setChatInput("");
311
+ setChatLoading(true);
312
+ try {
313
+ const res = await fetch("/api/code-line-ask", {
314
+ method: "POST",
315
+ headers: { "Content-Type": "application/json" },
316
+ body: JSON.stringify({
317
+ filePath,
318
+ selectedCode,
319
+ prompt: promptText,
320
+ questionId: currentQuestion?.id,
321
+ topicId: currentQuestion?.topicId ?? selectedTopicId,
322
+ codeContextFiles: currentQuestion?.codeContextFiles,
323
+ codeSnippets,
324
+ preferenceSuffix: livePreferenceSuffix,
325
+ }),
326
+ });
327
+ const data = await res.json();
328
+ const annotation: CodeAnnotation = {
329
+ id: crypto.randomUUID(),
330
+ lineNumber: firstLine,
331
+ lineContent: firstLineContent,
332
+ prompt: promptText,
333
+ response: data.response ?? "No response.",
334
+ filePath,
335
+ };
336
+ const nextAnnotations = [
337
+ ...codeAnnotationsRef.current.filter((a) => a.lineNumber !== firstLine),
338
+ annotation,
339
+ ];
340
+ setCodeAnnotations(nextAnnotations);
341
+ setOpenAnnotationId(annotation.id);
342
+ setChatContextLines(new Set());
343
+
344
+ // persist to server if we have a question context
345
+ const qId = currentQuestionIdRef.current;
346
+ if (qId) {
347
+ fetch(`/api/questions/${qId}/code-annotations`, {
348
+ method: "PATCH",
349
+ headers: { "Content-Type": "application/json" },
350
+ body: JSON.stringify({ filePath, annotations: nextAnnotations }),
351
+ }).catch(() => {
352
+ /* non-fatal */
353
+ });
354
+ }
355
+ } catch (err) {
356
+ console.error("code-line-ask error:", err);
357
+ } finally {
358
+ setChatLoading(false);
359
+ }
360
+ }, [
361
+ chatInput,
362
+ chatLoading,
363
+ content,
364
+ chatContextLines,
365
+ filePath,
366
+ currentQuestion,
367
+ selectedTopicId,
368
+ codeSnippets,
369
+ livePreferenceSuffix,
370
+ ]);
371
+
166
372
  // ─── Drag ────────────────────────────────────────────
167
373
 
168
374
  const onTitleMouseDown = useCallback(
@@ -280,7 +486,14 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
280
486
  return () => document.removeEventListener("keydown", handler);
281
487
  }, [onClose]);
282
488
 
283
- const lang = getLang(filePath);
489
+ // Keep refs in sync with state for use inside [] dep callbacks
490
+ codeAnnotationsRef.current = codeAnnotations;
491
+ chatPanelOpenRef.current = chatPanelOpen;
492
+ const openAnnotation = openAnnotationId
493
+ ? (codeAnnotations.find((a) => a.id === openAnnotationId) ?? null)
494
+ : null;
495
+
496
+ const lang = isInline ? (inlineEntry?.language ?? "text") : getLang(filePath);
284
497
 
285
498
  const style: React.CSSProperties = maximized
286
499
  ? {
@@ -334,6 +547,46 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
334
547
  <Maximize2 className="w-3.5 h-3.5" />
335
548
  )}
336
549
  </button>
550
+ <button
551
+ onMouseDown={(e) => e.stopPropagation()}
552
+ onClick={() => {
553
+ if (!content) return;
554
+ let runCode: string;
555
+ if (selectedLines.size > 0) {
556
+ const lines = content.split("\n");
557
+ const sorted = [...selectedLines].sort((a, b) => a - b);
558
+ runCode = sorted.map((n) => lines[n - 1] ?? "").join("\n");
559
+ } else {
560
+ runCode = content;
561
+ }
562
+ const runLang =
563
+ lang === "typescript" || lang === "tsx"
564
+ ? "typescript"
565
+ : "javascript";
566
+ openCodeRunner(runCode, runLang);
567
+ }}
568
+ className="p-1 rounded transition-colors shrink-0 text-slate-500 hover:bg-slate-700 hover:text-emerald-400"
569
+ title={selectedLines.size > 0 ? "Run selected lines" : "Run file"}
570
+ >
571
+ <Play className="w-3.5 h-3.5" />
572
+ </button>
573
+ <button
574
+ onMouseDown={(e) => e.stopPropagation()}
575
+ onClick={() =>
576
+ setChatPanelOpen((v) => {
577
+ if (!v) setTimeout(() => chatInputRef.current?.focus(), 50);
578
+ return !v;
579
+ })
580
+ }
581
+ className={`p-1 rounded transition-colors shrink-0 ${
582
+ chatPanelOpen
583
+ ? "text-amber-400 bg-amber-500/10 hover:bg-amber-500/20"
584
+ : "text-slate-500 hover:bg-slate-700 hover:text-slate-300"
585
+ }`}
586
+ title="Code chat"
587
+ >
588
+ <MessageCircle className="w-3.5 h-3.5" />
589
+ </button>
337
590
  <button
338
591
  onClick={onClose}
339
592
  className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
@@ -362,20 +615,35 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
362
615
  showLineNumbers
363
616
  wrapLines
364
617
  wrapLongLines={false}
365
- lineProps={(lineNumber) => ({
366
- onClick: (e: React.MouseEvent) =>
367
- lineNumber !== undefined && handleLineClick(e, lineNumber),
368
- style: {
369
- display: "block",
370
- cursor: "pointer",
371
- backgroundColor: selectedLines.has(lineNumber)
372
- ? "rgba(6, 182, 212, 0.15)"
373
- : undefined,
374
- outline: selectedLines.has(lineNumber)
375
- ? "1px solid rgba(6, 182, 212, 0.3)"
376
- : undefined,
377
- },
378
- })}
618
+ lineProps={(lineNumber) => {
619
+ const hasAnnotation = codeAnnotations.some(
620
+ (a) => a.lineNumber === lineNumber,
621
+ );
622
+ const isChatCtx = chatContextLines.has(lineNumber);
623
+ const isSelected = selectedLines.has(lineNumber);
624
+ let bg: string | undefined;
625
+ let outline: string | undefined;
626
+ if (hasAnnotation) {
627
+ bg = "rgba(139, 92, 246, 0.2)";
628
+ outline = "1px solid rgba(139, 92, 246, 0.35)";
629
+ } else if (isChatCtx) {
630
+ bg = "rgba(245, 158, 11, 0.15)";
631
+ outline = "1px solid rgba(245, 158, 11, 0.3)";
632
+ } else if (isSelected) {
633
+ bg = "rgba(6, 182, 212, 0.15)";
634
+ outline = "1px solid rgba(6, 182, 212, 0.3)";
635
+ }
636
+ return {
637
+ onClick: (e: React.MouseEvent) =>
638
+ lineNumber !== undefined && handleLineClick(e, lineNumber),
639
+ style: {
640
+ display: "block",
641
+ cursor: "pointer",
642
+ backgroundColor: bg,
643
+ outline,
644
+ },
645
+ };
646
+ }}
379
647
  customStyle={{
380
648
  margin: 0,
381
649
  borderRadius: 0,
@@ -391,6 +659,108 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
391
659
  )}
392
660
  </div>
393
661
 
662
+ {/* ── Code chat panel ── */}
663
+ {chatPanelOpen && (
664
+ <div className="shrink-0 border-t border-amber-900/30 bg-slate-900/80">
665
+ {chatContextLines.size > 0 ? (
666
+ <div className="flex items-center gap-1 px-3 pt-2 pb-0.5 flex-wrap">
667
+ <span className="text-[10px] text-slate-500 shrink-0">
668
+ Context:
669
+ </span>
670
+ {[...chatContextLines]
671
+ .sort((a, b) => a - b)
672
+ .map((ln) => (
673
+ <span
674
+ key={ln}
675
+ className="inline-flex items-center gap-0.5 text-[10px] bg-amber-500/15 text-amber-400 px-1.5 py-0.5 rounded font-mono"
676
+ >
677
+ L{ln}
678
+ <button
679
+ onClick={() =>
680
+ setChatContextLines((prev) => {
681
+ const next = new Set(prev);
682
+ next.delete(ln);
683
+ return next;
684
+ })
685
+ }
686
+ className="hover:text-red-400 ml-0.5 leading-none"
687
+ >
688
+ ×
689
+ </button>
690
+ </span>
691
+ ))}
692
+ <button
693
+ onClick={() => setChatContextLines(new Set())}
694
+ className="text-[10px] text-slate-600 hover:text-slate-400 ml-1"
695
+ >
696
+ clear
697
+ </button>
698
+ </div>
699
+ ) : (
700
+ <p className="px-3 pt-2 pb-0 text-[10px] text-slate-600">
701
+ Click lines above to add them as context
702
+ </p>
703
+ )}
704
+ <div className="flex items-end gap-2 px-3 py-2">
705
+ <textarea
706
+ ref={chatInputRef}
707
+ value={chatInput}
708
+ onChange={(e) => setChatInput(e.target.value)}
709
+ onKeyDown={(e) => {
710
+ if (e.key === "Enter" && !e.shiftKey) {
711
+ e.preventDefault();
712
+ handleChatSend();
713
+ }
714
+ }}
715
+ placeholder="Ask about the selected lines…"
716
+ rows={2}
717
+ disabled={chatLoading}
718
+ className="flex-1 bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1.5 text-xs text-slate-200 placeholder-slate-600 focus:outline-none focus:border-amber-500/60 resize-none disabled:opacity-50 transition-colors"
719
+ />
720
+ <button
721
+ onClick={handleChatSend}
722
+ disabled={
723
+ chatLoading ||
724
+ !chatInput.trim() ||
725
+ chatContextLines.size === 0
726
+ }
727
+ className="shrink-0 self-end bg-amber-600 hover:bg-amber-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded-lg p-2 transition-colors"
728
+ title="Send (Enter)"
729
+ >
730
+ {chatLoading ? (
731
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
732
+ ) : (
733
+ <Send className="w-3.5 h-3.5" />
734
+ )}
735
+ </button>
736
+ </div>
737
+ {codeAnnotations.length > 0 && (
738
+ <div className="px-3 pb-2 flex flex-wrap gap-1">
739
+ {codeAnnotations.map((ann) => (
740
+ <button
741
+ key={ann.id}
742
+ onClick={() =>
743
+ setOpenAnnotationId((prev) =>
744
+ prev === ann.id ? null : ann.id,
745
+ )
746
+ }
747
+ className={`text-[10px] px-2 py-0.5 rounded-full border transition-colors ${
748
+ openAnnotationId === ann.id
749
+ ? "bg-violet-500/20 text-violet-300 border-violet-500/40"
750
+ : "bg-violet-500/10 text-violet-400 border-violet-500/20 hover:border-violet-500/40"
751
+ }`}
752
+ >
753
+ L{ann.lineNumber}:{" "}
754
+ {ann.prompt.length > 30
755
+ ? ann.prompt.slice(0, 30) + "…"
756
+ : ann.prompt}
757
+ </button>
758
+ ))}
759
+ </div>
760
+ )}
761
+ </div>
762
+ )}
763
+
394
764
  {/* ── Snippet selection toolbar ── */}
395
765
  {selectedLines.size > 0 && (
396
766
  <div className="shrink-0 border-t border-cyan-900/50 bg-slate-800 px-3 py-2 flex items-center gap-2">
@@ -465,6 +835,14 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
465
835
  </>
466
836
  )}
467
837
  </div>
838
+
839
+ {/* ── Code annotation popup ── */}
840
+ {openAnnotation && (
841
+ <CodeLineAnnotationPopup
842
+ annotation={openAnnotation}
843
+ onClose={() => setOpenAnnotationId(null)}
844
+ />
845
+ )}
468
846
  </>
469
847
  );
470
848
  }