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.
- package/README.md +62 -0
- package/index.js +302 -0
- package/package.json +44 -0
- package/template/.env.example +14 -0
- package/template/client/index.html +12 -0
- package/template/client/package-lock.json +6012 -0
- package/template/client/package.json +34 -0
- package/template/client/postcss.config.cjs +6 -0
- package/template/client/src/App.tsx +120 -0
- package/template/client/src/api.ts +132 -0
- package/template/client/src/components/AnnotationDialog.tsx +307 -0
- package/template/client/src/components/ChatMessage.tsx +89 -0
- package/template/client/src/components/ChatView.tsx +763 -0
- package/template/client/src/components/CodeContextPanel.tsx +470 -0
- package/template/client/src/components/FileAttachments.tsx +107 -0
- package/template/client/src/components/FileViewerModal.tsx +470 -0
- package/template/client/src/components/MarkdownRenderer.tsx +333 -0
- package/template/client/src/components/MermaidDiagram.tsx +157 -0
- package/template/client/src/components/Sidebar.tsx +419 -0
- package/template/client/src/components/TextAnnotator.tsx +476 -0
- package/template/client/src/index.css +61 -0
- package/template/client/src/main.tsx +10 -0
- package/template/client/src/store.ts +321 -0
- package/template/client/src/types.ts +65 -0
- package/template/client/src/vite-env.d.ts +1 -0
- package/template/client/tailwind.config.cjs +8 -0
- package/template/client/tsconfig.json +16 -0
- package/template/client/tsconfig.tsbuildinfo +1 -0
- package/template/client/vite.config.ts +12 -0
- package/template/cockpit.json +3 -0
- package/template/data/context-files/.gitkeep +0 -0
- package/template/data/questions/.gitkeep +0 -0
- package/template/data/topics.json +1 -0
- package/template/package.json +14 -0
- package/template/server/package-lock.json +2266 -0
- package/template/server/package.json +31 -0
- package/template/server/src/index.ts +758 -0
- package/template/server/src/storage.ts +303 -0
- 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>“{selectedText}
|
|
342
|
+
”
|
|
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: “
|
|
403
|
+
{ann.selectedText.length > 45
|
|
404
|
+
? ann.selectedText.slice(0, 45) + "…"
|
|
405
|
+
: ann.selectedText}
|
|
406
|
+
”
|
|
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
|
+
}
|