create-interview-cockpit 0.1.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 (39) hide show
  1. package/README.md +62 -0
  2. package/index.js +302 -0
  3. package/package.json +44 -0
  4. package/template/.env.example +14 -0
  5. package/template/client/index.html +12 -0
  6. package/template/client/package-lock.json +6012 -0
  7. package/template/client/package.json +34 -0
  8. package/template/client/postcss.config.cjs +6 -0
  9. package/template/client/src/App.tsx +120 -0
  10. package/template/client/src/api.ts +132 -0
  11. package/template/client/src/components/AnnotationDialog.tsx +307 -0
  12. package/template/client/src/components/ChatMessage.tsx +89 -0
  13. package/template/client/src/components/ChatView.tsx +763 -0
  14. package/template/client/src/components/CodeContextPanel.tsx +470 -0
  15. package/template/client/src/components/FileAttachments.tsx +107 -0
  16. package/template/client/src/components/FileViewerModal.tsx +470 -0
  17. package/template/client/src/components/MarkdownRenderer.tsx +333 -0
  18. package/template/client/src/components/MermaidDiagram.tsx +157 -0
  19. package/template/client/src/components/Sidebar.tsx +419 -0
  20. package/template/client/src/components/TextAnnotator.tsx +476 -0
  21. package/template/client/src/index.css +61 -0
  22. package/template/client/src/main.tsx +10 -0
  23. package/template/client/src/store.ts +321 -0
  24. package/template/client/src/types.ts +65 -0
  25. package/template/client/src/vite-env.d.ts +1 -0
  26. package/template/client/tailwind.config.cjs +8 -0
  27. package/template/client/tsconfig.json +16 -0
  28. package/template/client/tsconfig.tsbuildinfo +1 -0
  29. package/template/client/vite.config.ts +12 -0
  30. package/template/cockpit.json +3 -0
  31. package/template/data/context-files/.gitkeep +0 -0
  32. package/template/data/questions/.gitkeep +0 -0
  33. package/template/data/topics.json +1 -0
  34. package/template/package.json +14 -0
  35. package/template/server/package-lock.json +2266 -0
  36. package/template/server/package.json +31 -0
  37. package/template/server/src/index.ts +758 -0
  38. package/template/server/src/storage.ts +303 -0
  39. package/template/server/tsconfig.json +14 -0
@@ -0,0 +1,476 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
3
+ import type { Annotation, AnnotationFollowUp } from "../types";
4
+ import MarkdownRenderer from "./MarkdownRenderer";
5
+ import AnnotationDialog from "./AnnotationDialog";
6
+
7
+ interface Props {
8
+ content: string;
9
+ messageId: string;
10
+ annotations: Annotation[];
11
+ onAnnotationCreate: (annotation: Annotation) => void;
12
+ onAnnotationUpdate: (updated: Annotation) => void;
13
+ bookmarkedBlockIndex?: number;
14
+ onBookmarkBlock?: (blockIndex: number) => void;
15
+ responseLength?: string;
16
+ responseStyle?: string;
17
+ responseAudience?: string;
18
+ }
19
+
20
+ type Phase = "idle" | "button" | "input" | "loading";
21
+
22
+ // Optional inline markdown markers: **, __, *, _, ` (inline code spans)
23
+ const INLINE_MARKER = "(?:\\*{1,2}|_{1,2}|`)?";
24
+
25
+ /**
26
+ * Build a regex pattern from selectedText that tolerates inline markdown markers
27
+ * (**bold**, *italic*, `code`) being interspersed at any character boundary in
28
+ * the raw source. Allows an optional marker before the first char AND after the
29
+ * last char so fully-wrapped spans (e.g. **word**) are consumed cleanly.
30
+ */
31
+ function buildAnnotationPattern(text: string): string {
32
+ const parts = text.split(/(\s+)/);
33
+ const inner = parts
34
+ .map((part) => {
35
+ if (/^\s+$/.test(part)) {
36
+ const esc = part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
+ return `${INLINE_MARKER}${esc}${INLINE_MARKER}`;
38
+ }
39
+ return [...part]
40
+ .map((ch, i, arr) => {
41
+ const e = ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
+ return i < arr.length - 1 ? e + INLINE_MARKER : e;
43
+ })
44
+ .join("");
45
+ })
46
+ .join("");
47
+ // Optional marker at start AND end (handles opening and closing bold/code markers)
48
+ return `${INLINE_MARKER}${inner}${INLINE_MARKER}`;
49
+ }
50
+
51
+ function injectAnnotationLinks(
52
+ content: string,
53
+ annotations: Annotation[],
54
+ ): string {
55
+ if (!annotations.length) return content;
56
+
57
+ const sorted = [...annotations].sort(
58
+ (a, b) => b.selectedText.length - a.selectedText.length,
59
+ );
60
+
61
+ let result = content;
62
+ for (const ann of sorted) {
63
+ // Skip if already injected
64
+ if (result.includes(`annot://${ann.id}`)) continue;
65
+
66
+ // Strategy 1 — fuzzy regex: tolerates **, __, *, _, ` markers around/between chars
67
+ let matched = false;
68
+ try {
69
+ const pattern = buildAnnotationPattern(ann.selectedText);
70
+ const re = new RegExp(`(?<!\\[)${pattern}(?![^[\\]]*\\])`);
71
+ const m = re.exec(result);
72
+ if (m) {
73
+ result =
74
+ result.slice(0, m.index) +
75
+ `[${ann.selectedText}](annot://${ann.id})` +
76
+ result.slice(m.index + m[0].length);
77
+ matched = true;
78
+ }
79
+ } catch {
80
+ // Pattern too complex or invalid — fall through
81
+ }
82
+ if (matched) continue;
83
+
84
+ // Strategy 2 — code span: raw markdown has `selectedText` (backtick-wrapped).
85
+ // Replace `foo` with [`foo`](annot://id) so the link renders inside code styling.
86
+ const codeSpan = `\`${ann.selectedText}\``;
87
+ const codeIdx = result.indexOf(codeSpan);
88
+ if (codeIdx !== -1) {
89
+ result =
90
+ result.slice(0, codeIdx) +
91
+ `[\`${ann.selectedText}\`](annot://${ann.id})` +
92
+ result.slice(codeIdx + codeSpan.length);
93
+ continue;
94
+ }
95
+
96
+ // Strategy 3 — exact literal fallback (catches anything the fuzzy pass missed)
97
+ try {
98
+ const escaped = ann.selectedText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
+ const re = new RegExp(`(?<!\\[)${escaped}(?![^[\\]]*\\])`);
100
+ const m = re.exec(result);
101
+ if (m) {
102
+ result =
103
+ result.slice(0, m.index) +
104
+ `[${ann.selectedText}](annot://${ann.id})` +
105
+ result.slice(m.index + m[0].length);
106
+ }
107
+ } catch {
108
+ // Give up on this annotation
109
+ }
110
+ }
111
+ return result;
112
+ }
113
+
114
+ export default function TextAnnotator({
115
+ content,
116
+ messageId,
117
+ annotations,
118
+ onAnnotationCreate,
119
+ onAnnotationUpdate,
120
+ bookmarkedBlockIndex,
121
+ onBookmarkBlock,
122
+ responseLength,
123
+ responseStyle,
124
+ responseAudience,
125
+ }: Props) {
126
+ const containerRef = useRef<HTMLDivElement>(null);
127
+ const annotationsRef = useRef(annotations);
128
+ annotationsRef.current = annotations;
129
+
130
+ const [phase, setPhase] = useState<Phase>("idle");
131
+ const phaseRef = useRef(phase);
132
+ phaseRef.current = phase;
133
+ const [selectedText, setSelectedText] = useState("");
134
+ const [floatingPos, setFloatingPos] = useState({ x: 0, y: 0 });
135
+ const [inputValue, setInputValue] = useState("");
136
+ const [expandedAnnotationId, setExpandedAnnotationId] = useState<
137
+ string | null
138
+ >(null);
139
+ const [followUpInput, setFollowUpInput] = useState("");
140
+ const [followUpLoading, setFollowUpLoading] = useState(false);
141
+ const scrollBodyRef = useRef<HTMLDivElement>(null);
142
+
143
+ // Dialog state — opened when clicking an annotation link in the rendered text
144
+ const [dialogAnnotationId, setDialogAnnotationId] = useState<string | null>(
145
+ null,
146
+ );
147
+ const [dialogPos, setDialogPos] = useState<
148
+ { x: number; y: number } | undefined
149
+ >();
150
+ const dialogAnnotation = dialogAnnotationId
151
+ ? (annotations.find((a) => a.id === dialogAnnotationId) ?? null)
152
+ : null;
153
+
154
+ // Stable callback — clicking an annot:// link opens the dialog
155
+ const handleAnnotationClick = useCallback(
156
+ (annotId: string, rect: DOMRect) => {
157
+ // Position dialog to the right of the clicked text, or fallback to center
158
+ const x = Math.min(rect.right + 16, window.innerWidth - 496);
159
+ const y = Math.max(16, rect.top - 220);
160
+ setDialogPos({ x: Math.max(8, x), y });
161
+ setDialogAnnotationId((prev) => (prev === annotId ? null : annotId));
162
+ },
163
+ [],
164
+ );
165
+
166
+ useEffect(() => {
167
+ const handleMouseUp = () => {
168
+ // Read phase via ref — no stale closure, no dep needed
169
+ if (phaseRef.current === "input" || phaseRef.current === "loading")
170
+ return;
171
+
172
+ const selection = window.getSelection();
173
+ if (!selection || selection.isCollapsed) {
174
+ setPhase((p) => (p === "button" ? "idle" : p));
175
+ return;
176
+ }
177
+ const text = selection.toString().trim();
178
+ if (!text) return;
179
+
180
+ if (!containerRef.current) return;
181
+ const range = selection.getRangeAt(0);
182
+ if (!containerRef.current.contains(range.commonAncestorContainer)) return;
183
+
184
+ const rect = range.getBoundingClientRect();
185
+ setSelectedText(text);
186
+ setFloatingPos({ x: rect.left + rect.width / 2, y: rect.top });
187
+ setPhase("button");
188
+ };
189
+
190
+ document.addEventListener("mouseup", handleMouseUp);
191
+ return () => document.removeEventListener("mouseup", handleMouseUp);
192
+ }, []); // Register once — phase is read via ref
193
+
194
+ const handleButtonClick = () => {
195
+ setInputValue("");
196
+ setPhase("input");
197
+ };
198
+
199
+ const handleSubmit = async () => {
200
+ if (!inputValue.trim()) return;
201
+ setPhase("loading");
202
+ try {
203
+ const res = await fetch("/api/inline-ask", {
204
+ method: "POST",
205
+ headers: { "Content-Type": "application/json" },
206
+ body: JSON.stringify({
207
+ selectedText,
208
+ prompt: inputValue.trim(),
209
+ messageContent: content,
210
+ responseLength,
211
+ responseStyle,
212
+ responseAudience,
213
+ }),
214
+ });
215
+ const data = await res.json();
216
+ const annotation: Annotation = {
217
+ id: crypto.randomUUID(),
218
+ messageId,
219
+ selectedText,
220
+ prompt: inputValue.trim(),
221
+ response: data.response ?? "No response.",
222
+ createdAt: new Date().toISOString(),
223
+ };
224
+ onAnnotationCreate(annotation);
225
+ } catch {
226
+ // silently reset on error
227
+ } finally {
228
+ setPhase("idle");
229
+ setInputValue("");
230
+ setSelectedText("");
231
+ }
232
+ };
233
+
234
+ const handleFollowUpSubmit = async () => {
235
+ if (!followUpInput.trim() || !expandedAnnotationId) return;
236
+ const annotation = annotationsRef.current.find(
237
+ (a) => a.id === expandedAnnotationId,
238
+ );
239
+ if (!annotation) return;
240
+ setFollowUpLoading(true);
241
+ try {
242
+ const res = await fetch("/api/inline-ask", {
243
+ method: "POST",
244
+ headers: { "Content-Type": "application/json" },
245
+ body: JSON.stringify({
246
+ selectedText: annotation.selectedText,
247
+ prompt: followUpInput.trim(),
248
+ messageContent: content,
249
+ priorResponse: annotation.response,
250
+ followUps: annotation.followUps ?? [],
251
+ responseLength,
252
+ responseStyle,
253
+ responseAudience,
254
+ }),
255
+ });
256
+ const data = await res.json();
257
+ const newFollowUp: AnnotationFollowUp = {
258
+ id: crypto.randomUUID(),
259
+ prompt: followUpInput.trim(),
260
+ response: data.response ?? "No response.",
261
+ createdAt: new Date().toISOString(),
262
+ };
263
+ const updated: Annotation = {
264
+ ...annotation,
265
+ followUps: [...(annotation.followUps ?? []), newFollowUp],
266
+ };
267
+ onAnnotationUpdate(updated);
268
+ setFollowUpInput("");
269
+ setTimeout(() => {
270
+ scrollBodyRef.current?.scrollTo({
271
+ top: scrollBodyRef.current.scrollHeight,
272
+ behavior: "smooth",
273
+ });
274
+ }, 50);
275
+ } catch {
276
+ // silently ignore
277
+ } finally {
278
+ setFollowUpLoading(false);
279
+ }
280
+ };
281
+
282
+ const processedContent = injectAnnotationLinks(content, annotations);
283
+
284
+ return (
285
+ <div ref={containerRef} className="relative">
286
+ <MarkdownRenderer
287
+ content={processedContent}
288
+ onAnnotationClick={handleAnnotationClick}
289
+ bookmarkedBlockIndex={bookmarkedBlockIndex}
290
+ onBookmarkBlock={onBookmarkBlock}
291
+ />
292
+
293
+ {/* Annotation dialog — opened by clicking an underlined annotation link */}
294
+ {dialogAnnotation && (
295
+ <AnnotationDialog
296
+ annotation={dialogAnnotation}
297
+ onClose={() => setDialogAnnotationId(null)}
298
+ onUpdate={onAnnotationUpdate}
299
+ messageContent={content}
300
+ initialPos={dialogPos}
301
+ responseLength={responseLength}
302
+ responseStyle={responseStyle}
303
+ responseAudience={responseAudience}
304
+ />
305
+ )}
306
+
307
+ {/* Floating "Ask about this" button */}
308
+ {phase === "button" && (
309
+ <button
310
+ style={{
311
+ position: "fixed",
312
+ left: floatingPos.x,
313
+ top: floatingPos.y,
314
+ transform: "translate(-50%, calc(-100% - 6px))",
315
+ zIndex: 60,
316
+ }}
317
+ onMouseDown={(e) => e.preventDefault()} // prevent selection clear
318
+ onClick={handleButtonClick}
319
+ className="bg-cyan-700 hover:bg-cyan-600 text-white text-xs font-medium px-2.5 py-1 rounded-md shadow-lg border border-cyan-500/40 whitespace-nowrap"
320
+ >
321
+ Ask about this
322
+ </button>
323
+ )}
324
+
325
+ {/* Floating input panel */}
326
+ {(phase === "input" || phase === "loading") && (
327
+ <div
328
+ style={{
329
+ position: "fixed",
330
+ left: Math.min(
331
+ Math.max(floatingPos.x, 160),
332
+ window.innerWidth - 160,
333
+ ),
334
+ top: floatingPos.y,
335
+ transform: "translate(-50%, calc(-100% - 10px))",
336
+ zIndex: 60,
337
+ }}
338
+ className="bg-slate-800 border border-slate-600 rounded-xl shadow-xl p-3 w-72"
339
+ >
340
+ <p className="text-xs text-slate-400 mb-2 truncate">
341
+ <span className="text-slate-500">re: </span>&ldquo;{selectedText}
342
+ &rdquo;
343
+ </p>
344
+ <textarea
345
+ autoFocus
346
+ value={inputValue}
347
+ onChange={(e) => setInputValue(e.target.value)}
348
+ onKeyDown={(e) => {
349
+ if (e.key === "Enter" && !e.shiftKey) {
350
+ e.preventDefault();
351
+ handleSubmit();
352
+ }
353
+ if (e.key === "Escape") setPhase("idle");
354
+ }}
355
+ placeholder="Ask something about this…"
356
+ className="w-full bg-slate-900 text-slate-200 text-xs rounded-lg p-2 resize-none outline-none border border-slate-700 focus:border-cyan-500 transition-colors"
357
+ rows={2}
358
+ disabled={phase === "loading"}
359
+ />
360
+ <div className="flex justify-end gap-2 mt-2">
361
+ <button
362
+ onClick={() => setPhase("idle")}
363
+ className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
364
+ >
365
+ Cancel
366
+ </button>
367
+ <button
368
+ onClick={handleSubmit}
369
+ disabled={phase === "loading" || !inputValue.trim()}
370
+ className="text-xs bg-cyan-700 hover:bg-cyan-600 disabled:opacity-50 disabled:cursor-not-allowed text-white px-2.5 py-1 rounded-md transition-colors"
371
+ >
372
+ {phase === "loading" ? "Asking…" : "Ask"}
373
+ </button>
374
+ </div>
375
+ </div>
376
+ )}
377
+
378
+ {/* Annotation threads — inline tree below content */}
379
+ {annotations.length > 0 && (
380
+ <div className="mt-4 ml-1 border-l-2 border-slate-700/40">
381
+ {annotations.map((ann, i) => {
382
+ const isLast = i === annotations.length - 1;
383
+ const isExpanded = expandedAnnotationId === ann.id;
384
+ return (
385
+ <div key={ann.id}>
386
+ {/* Stub row */}
387
+ <button
388
+ onClick={() => {
389
+ setExpandedAnnotationId(isExpanded ? null : ann.id);
390
+ setFollowUpInput("");
391
+ }}
392
+ className="flex items-start gap-1.5 group w-full text-left pl-2 py-0.5 hover:bg-slate-800/40 rounded-sm transition-colors"
393
+ >
394
+ <span className="font-mono text-slate-600 text-xs shrink-0 select-none mt-0.5">
395
+ {isLast ? "└─" : "├─"}
396
+ </span>
397
+ <div className="flex-1 min-w-0">
398
+ <span className="text-xs text-slate-400 group-hover:text-slate-200 transition-colors">
399
+ {ann.prompt}
400
+ </span>{" "}
401
+ <span className="text-[10px] text-slate-600 italic">
402
+ re: &ldquo;
403
+ {ann.selectedText.length > 45
404
+ ? ann.selectedText.slice(0, 45) + "…"
405
+ : ann.selectedText}
406
+ &rdquo;
407
+ </span>
408
+ {(ann.followUps?.length ?? 0) > 0 && (
409
+ <span className="ml-1.5 text-[10px] text-cyan-600/60">
410
+ +{ann.followUps!.length}
411
+ </span>
412
+ )}
413
+ </div>
414
+ {isExpanded ? (
415
+ <ChevronDown className="w-3 h-3 text-slate-500 shrink-0 mt-0.5" />
416
+ ) : (
417
+ <ChevronRight className="w-3 h-3 text-slate-600 shrink-0 mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity" />
418
+ )}
419
+ </button>
420
+
421
+ {/* Expanded inline thread */}
422
+ {isExpanded && (
423
+ <div className="ml-5 mr-1 mt-1 mb-2 border border-slate-700/50 rounded-lg bg-slate-800/50 flex flex-col overflow-hidden max-h-[420px]">
424
+ <div
425
+ ref={scrollBodyRef}
426
+ className="overflow-y-auto flex-1 px-3 py-2.5 text-sm"
427
+ >
428
+ <MarkdownRenderer content={ann.response} />
429
+ {(ann.followUps ?? []).map((fu) => (
430
+ <div key={fu.id}>
431
+ <hr className="border-slate-700 my-3" />
432
+ <p className="text-[11px] text-slate-500 mb-1">
433
+ ↳ You: {fu.prompt}
434
+ </p>
435
+ <MarkdownRenderer content={fu.response} />
436
+ </div>
437
+ ))}
438
+ </div>
439
+ <div className="shrink-0 border-t border-slate-700/60 bg-slate-800 px-2.5 py-2 flex gap-2 items-end">
440
+ <textarea
441
+ autoFocus
442
+ value={followUpInput}
443
+ onChange={(e) => setFollowUpInput(e.target.value)}
444
+ onKeyDown={(e) => {
445
+ if (e.key === "Enter" && !e.shiftKey) {
446
+ e.preventDefault();
447
+ handleFollowUpSubmit();
448
+ }
449
+ }}
450
+ placeholder="Ask a follow-up…"
451
+ rows={1}
452
+ disabled={followUpLoading}
453
+ className="flex-1 bg-slate-900 text-slate-200 text-xs rounded-lg px-2 py-1.5 resize-none outline-none border border-slate-700 focus:border-cyan-500 transition-colors disabled:opacity-50"
454
+ />
455
+ <button
456
+ onClick={handleFollowUpSubmit}
457
+ disabled={followUpLoading || !followUpInput.trim()}
458
+ className="shrink-0 bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs px-2.5 py-1.5 rounded-lg transition-colors flex items-center gap-1"
459
+ >
460
+ {followUpLoading ? (
461
+ <Loader2 size={12} className="animate-spin" />
462
+ ) : (
463
+ "Ask"
464
+ )}
465
+ </button>
466
+ </div>
467
+ </div>
468
+ )}
469
+ </div>
470
+ );
471
+ })}
472
+ </div>
473
+ )}
474
+ </div>
475
+ );
476
+ }
@@ -0,0 +1,61 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ body {
6
+ margin: 0;
7
+ min-height: 100vh;
8
+ }
9
+
10
+ #root {
11
+ min-height: 100vh;
12
+ }
13
+
14
+ /* Mermaid diagram styling */
15
+ .mermaid-diagram svg {
16
+ max-width: 100%;
17
+ }
18
+
19
+ /* Scrollbar styling */
20
+ ::-webkit-scrollbar {
21
+ width: 6px;
22
+ height: 6px;
23
+ }
24
+
25
+ ::-webkit-scrollbar-track {
26
+ background: transparent;
27
+ }
28
+
29
+ ::-webkit-scrollbar-thumb {
30
+ background: #475569;
31
+ border-radius: 3px;
32
+ }
33
+
34
+ ::-webkit-scrollbar-thumb:hover {
35
+ background: #64748b;
36
+ }
37
+
38
+ /* Fade-in animation */
39
+ @keyframes fadeIn {
40
+ from {
41
+ opacity: 0;
42
+ transform: translateY(4px);
43
+ }
44
+ to {
45
+ opacity: 1;
46
+ transform: translateY(0);
47
+ }
48
+ }
49
+
50
+ .animate-fadeIn {
51
+ animation: fadeIn 0.2s ease-out;
52
+ }
53
+
54
+ /* Prose overrides for dark theme code */
55
+ .prose pre {
56
+ background: #1e293b !important;
57
+ }
58
+
59
+ .prose code {
60
+ color: #67e8f9 !important;
61
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import App from "./App";
4
+ import "./index.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );