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,128 @@
1
+ import { useEffect, useState } from "react";
2
+ import { X, MessageSquare, Check, Loader2 } from "lucide-react";
3
+ import type { Question } from "../types";
4
+ import { useStore } from "../store";
5
+
6
+ interface Props {
7
+ question: Question;
8
+ topicId: string;
9
+ onClose: () => void;
10
+ }
11
+
12
+ export default function LinkedConvosPicker({
13
+ question,
14
+ topicId,
15
+ onClose,
16
+ }: Props) {
17
+ const { linkConversation, unlinkConversation } = useStore();
18
+ const [siblings, setSiblings] = useState<Question[]>([]);
19
+ const [loading, setLoading] = useState(true);
20
+ const [pending, setPending] = useState<string | null>(null);
21
+
22
+ useEffect(() => {
23
+ fetch(`/api/topics/${topicId}/questions`)
24
+ .then((r) => r.json())
25
+ .then((qs: Question[]) => {
26
+ // Exclude the current question itself
27
+ setSiblings(qs.filter((q2) => q2.id !== question.id));
28
+ })
29
+ .finally(() => setLoading(false));
30
+ }, [topicId, question.id]);
31
+
32
+ const linked = new Set(question.linkedConversationIds ?? []);
33
+
34
+ const toggle = async (targetId: string) => {
35
+ setPending(targetId);
36
+ try {
37
+ if (linked.has(targetId)) {
38
+ await unlinkConversation(question.id, targetId);
39
+ } else {
40
+ await linkConversation(question.id, targetId);
41
+ }
42
+ } finally {
43
+ setPending(null);
44
+ }
45
+ };
46
+
47
+ return (
48
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
49
+ <div className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[70vh]">
50
+ {/* Header */}
51
+ <div className="flex items-center gap-2 px-4 py-3 border-b border-slate-700 shrink-0">
52
+ <MessageSquare className="w-4 h-4 text-violet-400 shrink-0" />
53
+ <span className="text-sm font-semibold text-slate-200 flex-1">
54
+ Link conversations as context
55
+ </span>
56
+ <button
57
+ onClick={onClose}
58
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors"
59
+ >
60
+ <X className="w-4 h-4" />
61
+ </button>
62
+ </div>
63
+
64
+ {/* Body */}
65
+ <div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1">
66
+ {loading && (
67
+ <div className="flex items-center justify-center py-8">
68
+ <Loader2 className="w-5 h-5 text-violet-400 animate-spin" />
69
+ </div>
70
+ )}
71
+ {!loading && siblings.length === 0 && (
72
+ <p className="text-xs text-slate-500 text-center py-8">
73
+ No other conversations in this topic yet.
74
+ </p>
75
+ )}
76
+ {!loading &&
77
+ siblings.map((sibling) => {
78
+ const isLinked = linked.has(sibling.id);
79
+ const isPending = pending === sibling.id;
80
+ const msgCount = sibling.messages?.length ?? 0;
81
+ return (
82
+ <button
83
+ key={sibling.id}
84
+ onClick={() => toggle(sibling.id)}
85
+ disabled={isPending}
86
+ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
87
+ isLinked
88
+ ? "bg-violet-500/15 border border-violet-500/30 hover:bg-violet-500/20"
89
+ : "bg-slate-800/50 border border-transparent hover:bg-slate-800"
90
+ }`}
91
+ >
92
+ <div
93
+ className={`w-4 h-4 rounded border shrink-0 flex items-center justify-center transition-colors ${
94
+ isLinked
95
+ ? "bg-violet-500 border-violet-500"
96
+ : "border-slate-600"
97
+ }`}
98
+ >
99
+ {isPending ? (
100
+ <Loader2 className="w-2.5 h-2.5 animate-spin text-white" />
101
+ ) : isLinked ? (
102
+ <Check className="w-2.5 h-2.5 text-white" />
103
+ ) : null}
104
+ </div>
105
+ <div className="flex-1 min-w-0">
106
+ <p className="text-xs font-medium text-slate-200 truncate">
107
+ {sibling.title}
108
+ </p>
109
+ <p className="text-[10px] text-slate-500 mt-0.5">
110
+ {msgCount} message{msgCount !== 1 ? "s" : ""}
111
+ </p>
112
+ </div>
113
+ </button>
114
+ );
115
+ })}
116
+ </div>
117
+
118
+ {/* Footer hint */}
119
+ <div className="px-4 py-2.5 border-t border-slate-800 shrink-0">
120
+ <p className="text-[10px] text-slate-500">
121
+ Linked conversations are included as read-only background context
122
+ for the AI.
123
+ </p>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
@@ -1,12 +1,13 @@
1
1
  import ReactMarkdown from "react-markdown";
2
2
  import remarkGfm from "remark-gfm";
3
- import { useMemo, useRef, useEffect } from "react";
3
+ import { useMemo, useRef, useEffect, useState } from "react";
4
4
  import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5
5
  import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
6
6
  import MermaidDiagram from "./MermaidDiagram";
7
+ import PlotEmbed from "./PlotEmbed";
7
8
  import VizCraftEmbed from "./VizCraftEmbed";
8
9
  import { useStore } from "../store";
9
- import { Bookmark } from "lucide-react";
10
+ import { Bookmark, ExternalLink, Play, Save, Check } from "lucide-react";
10
11
 
11
12
  import type { Components } from "react-markdown";
12
13
 
@@ -15,6 +16,149 @@ interface Props {
15
16
  onAnnotationClick?: (annotId: string, rect: DOMRect) => void;
16
17
  bookmarkedBlockIndex?: number;
17
18
  onBookmarkBlock?: (blockIndex: number) => void;
19
+ onSpecRefined?: (originalSpec: string, newSpec: string) => void;
20
+ }
21
+
22
+ // ── Inline code block (AI-written code that can be opened in FileViewerModal) ──
23
+ // Parses an optional `// @ref: some-id` first-line directive so the AI can link
24
+ // back to this block anywhere in its response via [Label](inlineref://some-id).
25
+ function InlineCodeBlock({
26
+ codeString,
27
+ language,
28
+ }: {
29
+ codeString: string;
30
+ language: string;
31
+ }) {
32
+ const registerInlineCode = useStore((s) => s.registerInlineCode);
33
+ const openFileViewer = useStore((s) => s.openFileViewer);
34
+ const openCodeRunner = useStore((s) => s.openCodeRunner);
35
+ const currentQuestion = useStore((s) => s.currentQuestion);
36
+ const saveCodeSnippetToQuestion = useStore(
37
+ (s) => s.saveCodeSnippetToQuestion,
38
+ );
39
+ const [savedAi, setSavedAi] = useState<boolean>(false);
40
+
41
+ // Parse optional @ref directive on first line (strips it from display)
42
+ const parsed = useMemo(() => {
43
+ const firstLine = codeString.split("\n")[0]?.trim() ?? "";
44
+ const refMatch = firstLine.match(/^(?:\/\/|#|--)\s*@ref:\s*(.+)$/);
45
+ if (refMatch) {
46
+ const label = refMatch[1].trim();
47
+ return {
48
+ id: `inline:${label}`,
49
+ label,
50
+ displayCode: codeString.split("\n").slice(1).join("\n"),
51
+ };
52
+ }
53
+ return { id: null, label: `${language} snippet`, displayCode: codeString };
54
+ }, [codeString, language]);
55
+
56
+ // Stable UUID for blocks without an explicit @ref
57
+ const stableIdRef = useRef(`inline:${crypto.randomUUID()}`);
58
+ const finalId = parsed.id ?? stableIdRef.current;
59
+
60
+ // Register on mount when there is an explicit @ref so inlineref:// links resolve.
61
+ // Guard: skip the store update if the entry is already registered with the same
62
+ // content — this prevents an infinite render loop when the component remounts
63
+ // because a parent re-renders (each store update would otherwise remount this,
64
+ // triggering the effect again, updating the store again…).
65
+ useEffect(() => {
66
+ if (!parsed.id) return;
67
+ const existing = useStore.getState().inlineCodeSnippets[parsed.id];
68
+ if (
69
+ existing?.content === parsed.displayCode &&
70
+ existing?.language === language
71
+ )
72
+ return;
73
+ registerInlineCode(parsed.id, {
74
+ content: parsed.displayCode,
75
+ language,
76
+ label: parsed.label,
77
+ });
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, [parsed.id]);
80
+
81
+ const handlePopout = () => {
82
+ registerInlineCode(finalId, {
83
+ content: parsed.displayCode,
84
+ language,
85
+ label: parsed.label,
86
+ });
87
+ openFileViewer(finalId);
88
+ };
89
+
90
+ const handleRun = () => {
91
+ const runLang =
92
+ language === "typescript" || language === "tsx"
93
+ ? "typescript"
94
+ : "javascript";
95
+ openCodeRunner(parsed.displayCode, runLang);
96
+ };
97
+
98
+ const handleSaveAi = async () => {
99
+ if (!currentQuestion || savedAi) return;
100
+ await saveCodeSnippetToQuestion(
101
+ currentQuestion.id,
102
+ parsed.displayCode,
103
+ language,
104
+ parsed.label,
105
+ "ai",
106
+ );
107
+ setSavedAi(true);
108
+ setTimeout(() => setSavedAi(false), 2000);
109
+ };
110
+
111
+ return (
112
+ <div className="relative group/codeblock">
113
+ <div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-0.5 opacity-0 group-hover/codeblock:opacity-100 transition-all">
114
+ <button
115
+ type="button"
116
+ onClick={handleRun}
117
+ className="p-1 rounded bg-slate-700/80 hover:bg-slate-600/90 text-slate-400 hover:text-emerald-400 transition-colors"
118
+ title="Run in code runner"
119
+ >
120
+ <Play className="w-3.5 h-3.5" />
121
+ </button>
122
+ {currentQuestion && (
123
+ <button
124
+ type="button"
125
+ onClick={handleSaveAi}
126
+ className={`p-1 rounded bg-slate-700/80 hover:bg-slate-600/90 transition-colors ${
127
+ savedAi ? "text-cyan-400" : "text-slate-400 hover:text-cyan-400"
128
+ }`}
129
+ title="Save to question context"
130
+ >
131
+ {savedAi ? (
132
+ <Check className="w-3.5 h-3.5" />
133
+ ) : (
134
+ <Save className="w-3.5 h-3.5" />
135
+ )}
136
+ </button>
137
+ )}
138
+ <button
139
+ type="button"
140
+ onClick={handlePopout}
141
+ className="p-1 rounded bg-slate-700/80 hover:bg-slate-600/90 text-slate-400 hover:text-violet-400 transition-colors"
142
+ title="Open in code viewer"
143
+ >
144
+ <ExternalLink className="w-3.5 h-3.5" />
145
+ </button>
146
+ </div>
147
+ <SyntaxHighlighter
148
+ style={oneDark}
149
+ language={language}
150
+ PreTag="div"
151
+ customStyle={{
152
+ margin: "0.5rem 0",
153
+ borderRadius: "0.5rem",
154
+ fontSize: "0.8rem",
155
+ background: "#1e293b",
156
+ }}
157
+ >
158
+ {parsed.displayCode}
159
+ </SyntaxHighlighter>
160
+ </div>
161
+ );
18
162
  }
19
163
 
20
164
  const markdownComponents: Components = {
@@ -35,6 +179,10 @@ const markdownComponents: Components = {
35
179
  return <MermaidDiagram chart={codeString} />;
36
180
  }
37
181
 
182
+ if (lang === "plot" || lang === "vega" || lang === "vega-lite") {
183
+ return <PlotEmbed spec={codeString} />;
184
+ }
185
+
38
186
  if (lang === "viz") {
39
187
  return <VizCraftEmbed spec={codeString} />;
40
188
  }
@@ -179,8 +327,10 @@ export default function MarkdownRenderer({
179
327
  onAnnotationClick,
180
328
  bookmarkedBlockIndex,
181
329
  onBookmarkBlock,
330
+ onSpecRefined,
182
331
  }: Props) {
183
332
  const openFileViewer = useStore((s) => s.openFileViewer);
333
+ const openDocViewer = useStore((s) => s.openDocViewer);
184
334
  const bookmarkElemRef = useRef<HTMLDivElement | null>(null);
185
335
 
186
336
  // Scroll the bookmarked block into view when the bookmark changes or on mount
@@ -237,6 +387,27 @@ export default function MarkdownRenderer({
237
387
 
238
388
  return {
239
389
  ...markdownComponents,
390
+ // Override the code renderer so viz blocks get the onSpecRefined callback
391
+ code({ className, children, ...props }) {
392
+ const match = /language-(\w+)/.exec(className || "");
393
+ const codeString = String(children).replace(/\n$/, "");
394
+ if (match) {
395
+ const lang = match[1];
396
+ if (lang === "mermaid") return <MermaidDiagram chart={codeString} />;
397
+ if (lang === "viz")
398
+ return (
399
+ <VizCraftEmbed spec={codeString} onSpecRefined={onSpecRefined} />
400
+ );
401
+ // Fenced code block: render with pop-out button
402
+ return <InlineCodeBlock codeString={codeString} language={lang} />;
403
+ }
404
+ // Delegate to the static handler for inline code / unsupported langs
405
+ return (markdownComponents.code as Function)({
406
+ className,
407
+ children,
408
+ ...props,
409
+ });
410
+ },
240
411
  p({ children, node }) {
241
412
  return wrapBlock(
242
413
  (node as any)?.position?.start?.line,
@@ -288,6 +459,35 @@ export default function MarkdownRenderer({
288
459
  </span>
289
460
  );
290
461
  }
462
+ if (href?.startsWith("docref://")) {
463
+ const withoutScheme = href.slice("docref://".length);
464
+ const qIdx = withoutScheme.indexOf("?");
465
+ const fileId =
466
+ qIdx === -1 ? withoutScheme : withoutScheme.slice(0, qIdx);
467
+ const params = new URLSearchParams(
468
+ qIdx === -1 ? "" : withoutScheme.slice(qIdx + 1),
469
+ );
470
+ const fileName = params.has("n")
471
+ ? decodeURIComponent(params.get("n")!)
472
+ : "Document";
473
+ const quote = String(
474
+ typeof children === "string"
475
+ ? children
476
+ : Array.isArray(children)
477
+ ? children.join("")
478
+ : "",
479
+ ).trim();
480
+ return (
481
+ <button
482
+ type="button"
483
+ onClick={() => openDocViewer(fileId, quote, fileName)}
484
+ className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 underline decoration-emerald-500/50 underline-offset-2 transition-colors text-[0.85em]"
485
+ title={`View in ${fileName}`}
486
+ >
487
+ {children}
488
+ </button>
489
+ );
490
+ }
291
491
  if (href?.startsWith("coderef://")) {
292
492
  const filePath = href.slice("coderef://".length);
293
493
  return (
@@ -301,6 +501,19 @@ export default function MarkdownRenderer({
301
501
  </button>
302
502
  );
303
503
  }
504
+ if (href?.startsWith("inlineref://")) {
505
+ const id = `inline:${href.slice("inlineref://".length)}`;
506
+ return (
507
+ <button
508
+ type="button"
509
+ onClick={() => openFileViewer(id)}
510
+ className="inline-flex items-center gap-1 text-violet-400 hover:text-violet-300 underline decoration-violet-500/50 underline-offset-2 transition-colors font-mono text-[0.8em]"
511
+ title="Open inline code block"
512
+ >
513
+ {children}
514
+ </button>
515
+ );
516
+ }
304
517
  return (
305
518
  <a
306
519
  href={href}
@@ -316,8 +529,10 @@ export default function MarkdownRenderer({
316
529
  }, [
317
530
  onAnnotationClick,
318
531
  openFileViewer,
532
+ openDocViewer,
319
533
  bookmarkedBlockIndex,
320
534
  onBookmarkBlock,
535
+ onSpecRefined,
321
536
  ]);
322
537
 
323
538
  return (
@@ -328,6 +543,8 @@ export default function MarkdownRenderer({
328
543
  // Allow our internal schemes; block javascript: for safety
329
544
  if (url.startsWith("annot://")) return url;
330
545
  if (url.startsWith("coderef://")) return url;
546
+ if (url.startsWith("docref://")) return url;
547
+ if (url.startsWith("inlineref://")) return url;
331
548
  if (url.startsWith("javascript:")) return "";
332
549
  return url;
333
550
  }}