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
@@ -0,0 +1,551 @@
1
+ import { useEffect, useRef, useState, useCallback, useMemo } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import {
5
+ X,
6
+ GripVertical,
7
+ Maximize2,
8
+ Minimize2,
9
+ Loader2,
10
+ FileText,
11
+ Search,
12
+ } from "lucide-react";
13
+
14
+ interface Props {
15
+ fileId: string;
16
+ quote: string;
17
+ fileName: string;
18
+ onClose: () => void;
19
+ }
20
+
21
+ const MIN_W = 480;
22
+ const MIN_H = 360;
23
+ const DEFAULT_W = 820;
24
+ const DEFAULT_H = 620;
25
+
26
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
27
+
28
+ const PDF_EXTS = new Set(["pdf"]);
29
+ const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
30
+ const MD_EXTS = new Set(["md", "mdx"]);
31
+
32
+ function getFileType(name: string): "pdf" | "image" | "md" | "text" {
33
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
34
+ if (PDF_EXTS.has(ext)) return "pdf";
35
+ if (IMAGE_EXTS.has(ext)) return "image";
36
+ if (MD_EXTS.has(ext)) return "md";
37
+ return "text";
38
+ }
39
+
40
+ /** Split `text` around the first case-insensitive occurrence of `needle`. */
41
+ function splitOnQuote(text: string, needle: string): [string, string, string] {
42
+ if (!needle) return [text, "", ""];
43
+ const idx = text.toLowerCase().indexOf(needle.toLowerCase());
44
+ if (idx === -1) return [text, "", ""];
45
+ return [
46
+ text.slice(0, idx),
47
+ text.slice(idx, idx + needle.length),
48
+ text.slice(idx + needle.length),
49
+ ];
50
+ }
51
+
52
+ function viewUrl(fileId: string, fileName: string) {
53
+ return `/api/context-files/${encodeURIComponent(fileId)}/view?name=${encodeURIComponent(fileName)}`;
54
+ }
55
+
56
+ export default function DocRefModal({
57
+ fileId,
58
+ quote,
59
+ fileName,
60
+ onClose,
61
+ }: Props) {
62
+ const fileType = getFileType(fileName);
63
+ const baseViewUrl = viewUrl(fileId, fileName);
64
+
65
+ // Text mode state
66
+ const [content, setContent] = useState<string | null>(null);
67
+ const [loading, setLoading] = useState(
68
+ fileType === "text" || fileType === "md",
69
+ );
70
+ const [fetchError, setFetchError] = useState<string | null>(null);
71
+ const highlightRef = useRef<HTMLElement | null>(null);
72
+
73
+ // For PDF files: toggle between native viewer and extracted-text view
74
+ // Chrome's native PDF viewer doesn't support #search= URL fragments
75
+ const [pdfViewMode, setPdfViewMode] = useState<"pdf" | "text">("pdf");
76
+ // What the user is typing in the search bar
77
+ const [searchInput, setSearchInput] = useState(() =>
78
+ quote.slice(0, 200).trim(),
79
+ );
80
+ // Search term applied to the text view (shared by text-file mode and PDF text mode)
81
+ const [textSearch, setTextSearch] = useState(() =>
82
+ quote.slice(0, 200).trim(),
83
+ );
84
+
85
+ const [maximized, setMaximized] = useState(false);
86
+ const [pos, setPos] = useState(() => ({
87
+ x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
88
+ y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
89
+ }));
90
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
91
+
92
+ const dragStart = useRef<{
93
+ mx: number;
94
+ my: number;
95
+ ox: number;
96
+ oy: number;
97
+ } | null>(null);
98
+ const resizeDir = useRef<ResizeDir>(null);
99
+ const resizeStart = useRef<{
100
+ mx: number;
101
+ my: number;
102
+ ox: number;
103
+ oy: number;
104
+ ow: number;
105
+ oh: number;
106
+ } | null>(null);
107
+ const savedPos = useRef(pos);
108
+ const savedSize = useRef(size);
109
+
110
+ // Fetch extracted text — for text/md files on mount, or for PDFs when text view is activated
111
+ useEffect(() => {
112
+ const needsText =
113
+ fileType === "text" ||
114
+ fileType === "md" ||
115
+ (fileType === "pdf" && pdfViewMode === "text");
116
+ if (!needsText) return;
117
+ if (content !== null) return; // already loaded, skip re-fetch
118
+ setLoading(true);
119
+ setFetchError(null);
120
+ fetch(`/api/context-files/${encodeURIComponent(fileId)}/content`)
121
+ .then((r) => r.json())
122
+ .then((d) => {
123
+ if (d.error) setFetchError(d.error);
124
+ else setContent(d.content as string);
125
+ })
126
+ .catch(() => setFetchError("Failed to load document."))
127
+ .finally(() => setLoading(false));
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, [fileId, fileType, pdfViewMode]);
130
+
131
+ // Auto-scroll to highlighted passage once text loads
132
+ useEffect(() => {
133
+ if (!content || !highlightRef.current) return;
134
+ const id = setTimeout(() => {
135
+ highlightRef.current?.scrollIntoView({
136
+ behavior: "smooth",
137
+ block: "center",
138
+ });
139
+ }, 80);
140
+ return () => clearTimeout(id);
141
+ }, [content]);
142
+
143
+ // Text-mode rendered content with current search term highlighted
144
+ const renderedContent = useMemo(() => {
145
+ if (!content) return null;
146
+ const needle = textSearch.trim();
147
+ const [before, match, after] = splitOnQuote(content, needle);
148
+ if (!match) return <span>{content}</span>;
149
+ return (
150
+ <>
151
+ {before}
152
+ <mark
153
+ ref={(el) => {
154
+ highlightRef.current = el;
155
+ }}
156
+ className="bg-yellow-400/30 text-yellow-100 rounded-sm px-0.5 not-italic"
157
+ >
158
+ {match}
159
+ </mark>
160
+ {after}
161
+ </>
162
+ );
163
+ }, [content, textSearch]);
164
+
165
+ const handleSearchSubmit = (e: React.FormEvent) => {
166
+ e.preventDefault();
167
+ const term = searchInput.trim();
168
+ setTextSearch(term);
169
+ if (fileType === "pdf") {
170
+ // Switch to text view — Chrome's native PDF viewer doesn't support URL-fragment search
171
+ setPdfViewMode("text");
172
+ }
173
+ };
174
+
175
+ // ─── Drag ────────────────────────────────────────────────────
176
+
177
+ const onTitleMouseDown = useCallback(
178
+ (e: React.MouseEvent) => {
179
+ if (maximized) return;
180
+ e.preventDefault();
181
+ dragStart.current = {
182
+ mx: e.clientX,
183
+ my: e.clientY,
184
+ ox: pos.x,
185
+ oy: pos.y,
186
+ };
187
+ },
188
+ [maximized, pos],
189
+ );
190
+
191
+ useEffect(() => {
192
+ const onMove = (e: MouseEvent) => {
193
+ const drag = dragStart.current;
194
+ const resize = resizeStart.current;
195
+ const dir = resizeDir.current;
196
+ if (drag) {
197
+ setPos({
198
+ x: Math.max(0, drag.ox + e.clientX - drag.mx),
199
+ y: Math.max(0, drag.oy + e.clientY - drag.my),
200
+ });
201
+ }
202
+ if (resize && dir) {
203
+ const dx = e.clientX - resize.mx;
204
+ const dy = e.clientY - resize.my;
205
+ setSize((prev) => {
206
+ let w = prev.w;
207
+ let h = prev.h;
208
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
209
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
210
+ if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
211
+ if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
212
+ return { w, h };
213
+ });
214
+ if (dir.includes("w"))
215
+ setPos((prev) => ({
216
+ ...prev,
217
+ x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
218
+ }));
219
+ if (dir.includes("n"))
220
+ setPos((prev) => ({
221
+ ...prev,
222
+ y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
223
+ }));
224
+ }
225
+ };
226
+ const onUp = () => {
227
+ dragStart.current = null;
228
+ resizeStart.current = null;
229
+ resizeDir.current = null;
230
+ };
231
+ document.addEventListener("mousemove", onMove);
232
+ document.addEventListener("mouseup", onUp);
233
+ return () => {
234
+ document.removeEventListener("mousemove", onMove);
235
+ document.removeEventListener("mouseup", onUp);
236
+ };
237
+ }, []);
238
+
239
+ // ─── Resize handles ──────────────────────────────────────────
240
+
241
+ const startResize = useCallback(
242
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
243
+ if (maximized) return;
244
+ e.preventDefault();
245
+ e.stopPropagation();
246
+ resizeDir.current = dir;
247
+ resizeStart.current = {
248
+ mx: e.clientX,
249
+ my: e.clientY,
250
+ ox: pos.x,
251
+ oy: pos.y,
252
+ ow: size.w,
253
+ oh: size.h,
254
+ };
255
+ },
256
+ [maximized, pos, size],
257
+ );
258
+
259
+ // ─── Maximise ────────────────────────────────────────────────
260
+
261
+ const toggleMax = useCallback(() => {
262
+ if (!maximized) {
263
+ savedPos.current = pos;
264
+ savedSize.current = size;
265
+ setMaximized(true);
266
+ } else {
267
+ setPos(savedPos.current);
268
+ setSize(savedSize.current);
269
+ setMaximized(false);
270
+ }
271
+ }, [maximized, pos, size]);
272
+
273
+ useEffect(() => {
274
+ const handler = (e: KeyboardEvent) => {
275
+ if (e.key === "Escape") onClose();
276
+ };
277
+ document.addEventListener("keydown", handler);
278
+ return () => document.removeEventListener("keydown", handler);
279
+ }, [onClose]);
280
+
281
+ const modalStyle: React.CSSProperties = maximized
282
+ ? {
283
+ position: "fixed",
284
+ inset: 0,
285
+ width: "100vw",
286
+ height: "100vh",
287
+ borderRadius: 0,
288
+ }
289
+ : {
290
+ position: "fixed",
291
+ left: pos.x,
292
+ top: pos.y,
293
+ width: size.w,
294
+ height: size.h,
295
+ minWidth: MIN_W,
296
+ minHeight: MIN_H,
297
+ };
298
+
299
+ return (
300
+ <>
301
+ <div
302
+ className="fixed z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
303
+ style={modalStyle}
304
+ >
305
+ {/* ── Title bar ── */}
306
+ <div
307
+ className="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700 shrink-0"
308
+ onMouseDown={onTitleMouseDown}
309
+ style={{ cursor: maximized ? "default" : "grab" }}
310
+ >
311
+ <GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
312
+ <FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
313
+ <span
314
+ className="text-xs font-mono text-slate-300 truncate flex-1"
315
+ title={fileName}
316
+ >
317
+ {fileName}
318
+ </span>
319
+ <button
320
+ onClick={toggleMax}
321
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
322
+ title={maximized ? "Restore" : "Maximise"}
323
+ >
324
+ {maximized ? (
325
+ <Minimize2 className="w-3.5 h-3.5" />
326
+ ) : (
327
+ <Maximize2 className="w-3.5 h-3.5" />
328
+ )}
329
+ </button>
330
+ <button
331
+ onClick={onClose}
332
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
333
+ title="Close (Esc)"
334
+ >
335
+ <X className="w-3.5 h-3.5" />
336
+ </button>
337
+ </div>
338
+
339
+ {/* ── Search bar (PDF + text; hidden for images and markdown) ── */}
340
+ {fileType !== "image" && fileType !== "md" && (
341
+ <form
342
+ onSubmit={handleSearchSubmit}
343
+ className="flex items-center gap-2 px-3 py-1.5 bg-slate-800/60 border-b border-slate-700/60 shrink-0"
344
+ onMouseDown={(e) => e.stopPropagation()}
345
+ >
346
+ <Search className="w-3.5 h-3.5 text-slate-500 shrink-0" />
347
+ <input
348
+ type="text"
349
+ value={searchInput}
350
+ onChange={(e) => {
351
+ setSearchInput(e.target.value);
352
+ // Text mode: update live so highlight follows typing
353
+ if (fileType === "text") setTextSearch(e.target.value);
354
+ }}
355
+ placeholder="Search in document…"
356
+ className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-500 focus:outline-none min-w-0"
357
+ />
358
+ {fileType === "pdf" && (
359
+ <>
360
+ <button
361
+ type="submit"
362
+ className="px-2 py-0.5 text-[11px] rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition-colors shrink-0"
363
+ >
364
+ Find
365
+ </button>
366
+ {/* PDF / Text view toggle */}
367
+ <div className="flex items-center gap-px ml-1 shrink-0">
368
+ <button
369
+ type="button"
370
+ onClick={() => setPdfViewMode("pdf")}
371
+ className={`px-2 py-0.5 text-[11px] rounded-l transition-colors ${
372
+ pdfViewMode === "pdf"
373
+ ? "bg-emerald-700 text-emerald-100"
374
+ : "bg-slate-700 text-slate-400 hover:text-slate-200"
375
+ }`}
376
+ >
377
+ PDF
378
+ </button>
379
+ <button
380
+ type="button"
381
+ onClick={() => setPdfViewMode("text")}
382
+ className={`px-2 py-0.5 text-[11px] rounded-r transition-colors ${
383
+ pdfViewMode === "text"
384
+ ? "bg-emerald-700 text-emerald-100"
385
+ : "bg-slate-700 text-slate-400 hover:text-slate-200"
386
+ }`}
387
+ >
388
+ Text
389
+ </button>
390
+ </div>
391
+ </>
392
+ )}
393
+ </form>
394
+ )}
395
+
396
+ {/* ── Content area ── */}
397
+ <div className="flex-1 min-h-0 overflow-hidden">
398
+ {/* PDF — native viewer (default) */}
399
+ {fileType === "pdf" && pdfViewMode === "pdf" && (
400
+ <iframe
401
+ src={baseViewUrl}
402
+ className="w-full h-full border-0"
403
+ title={fileName}
404
+ />
405
+ )}
406
+
407
+ {/* PDF — extracted-text view with search highlight (Find button switches here) */}
408
+ {fileType === "pdf" && pdfViewMode === "text" && (
409
+ <>
410
+ {loading && (
411
+ <div className="flex items-center justify-center h-full">
412
+ <Loader2 className="w-5 h-5 text-emerald-400 animate-spin" />
413
+ </div>
414
+ )}
415
+ {fetchError && (
416
+ <div className="flex items-center justify-center h-full p-4">
417
+ <p className="text-sm text-red-400 text-center">
418
+ {fetchError}
419
+ </p>
420
+ </div>
421
+ )}
422
+ {!loading && !fetchError && content !== null && (
423
+ <div className="w-full h-full overflow-auto p-4">
424
+ <pre className="whitespace-pre-wrap text-slate-300 text-xs font-mono leading-relaxed">
425
+ {renderedContent}
426
+ </pre>
427
+ </div>
428
+ )}
429
+ </>
430
+ )}
431
+
432
+ {/* Image ── served directly from the view endpoint */}
433
+ {fileType === "image" && (
434
+ <div className="w-full h-full overflow-auto flex items-start justify-center p-4 bg-slate-950/40">
435
+ <img
436
+ src={baseViewUrl}
437
+ alt={fileName}
438
+ className="max-w-full object-contain rounded"
439
+ draggable={false}
440
+ />
441
+ </div>
442
+ )}
443
+
444
+ {/* Markdown — rendered with react-markdown */}
445
+ {fileType === "md" && (
446
+ <>
447
+ {loading && (
448
+ <div className="flex items-center justify-center h-full">
449
+ <Loader2 className="w-5 h-5 text-emerald-400 animate-spin" />
450
+ </div>
451
+ )}
452
+ {fetchError && (
453
+ <div className="flex items-center justify-center h-full p-4">
454
+ <p className="text-sm text-red-400 text-center">
455
+ {fetchError}
456
+ </p>
457
+ </div>
458
+ )}
459
+ {!loading && !fetchError && content !== null && (
460
+ <div className="w-full h-full overflow-auto p-5">
461
+ <div
462
+ className="prose prose-invert prose-sm max-w-none
463
+ prose-headings:text-slate-100 prose-headings:font-semibold
464
+ prose-p:text-slate-300 prose-p:leading-relaxed
465
+ prose-a:text-cyan-400 prose-a:no-underline hover:prose-a:underline
466
+ prose-code:text-emerald-300 prose-code:bg-slate-800 prose-code:px-1 prose-code:rounded
467
+ prose-pre:bg-slate-800/80 prose-pre:border prose-pre:border-slate-700
468
+ prose-blockquote:border-l-violet-500 prose-blockquote:text-slate-400
469
+ prose-strong:text-slate-100
470
+ prose-li:text-slate-300
471
+ prose-hr:border-slate-700
472
+ prose-table:text-slate-300
473
+ prose-th:text-slate-200 prose-th:border prose-th:border-slate-700 prose-th:bg-slate-800/60
474
+ prose-td:border prose-td:border-slate-700/60"
475
+ >
476
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
477
+ {content}
478
+ </ReactMarkdown>
479
+ </div>
480
+ </div>
481
+ )}
482
+ </>
483
+ )}
484
+
485
+ {/* Text / DOCX / other — extracted-text view with highlight */}
486
+ {fileType === "text" && (
487
+ <>
488
+ {loading && (
489
+ <div className="flex items-center justify-center h-full">
490
+ <Loader2 className="w-5 h-5 text-emerald-400 animate-spin" />
491
+ </div>
492
+ )}
493
+ {fetchError && (
494
+ <div className="flex items-center justify-center h-full p-4">
495
+ <p className="text-sm text-red-400 text-center">
496
+ {fetchError}
497
+ </p>
498
+ </div>
499
+ )}
500
+ {!loading && !fetchError && content !== null && (
501
+ <div className="w-full h-full overflow-auto p-4">
502
+ <pre className="whitespace-pre-wrap text-slate-300 text-xs font-mono leading-relaxed">
503
+ {renderedContent}
504
+ </pre>
505
+ </div>
506
+ )}
507
+ </>
508
+ )}
509
+ </div>
510
+
511
+ {/* ── Resize handles ── */}
512
+ {!maximized && (
513
+ <>
514
+ <div
515
+ onMouseDown={startResize("e")}
516
+ className="absolute top-0 right-0 w-1.5 h-full cursor-ew-resize z-10"
517
+ />
518
+ <div
519
+ onMouseDown={startResize("s")}
520
+ className="absolute bottom-0 left-0 w-full h-1.5 cursor-ns-resize z-10"
521
+ />
522
+ <div
523
+ onMouseDown={startResize("w")}
524
+ className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-10"
525
+ />
526
+ <div
527
+ onMouseDown={startResize("n")}
528
+ className="absolute top-0 left-0 w-full h-1.5 cursor-ns-resize z-10"
529
+ />
530
+ <div
531
+ onMouseDown={startResize("se")}
532
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
533
+ />
534
+ <div
535
+ onMouseDown={startResize("sw")}
536
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
537
+ />
538
+ <div
539
+ onMouseDown={startResize("ne")}
540
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
541
+ />
542
+ <div
543
+ onMouseDown={startResize("nw")}
544
+ className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
545
+ />
546
+ </>
547
+ )}
548
+ </div>
549
+ </>
550
+ );
551
+ }