create-interview-cockpit 0.4.0 → 0.5.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/package.json +1 -1
- package/template/client/package-lock.json +19 -0
- package/template/client/package.json +3 -0
- package/template/client/src/App.tsx +17 -0
- package/template/client/src/api.ts +135 -0
- package/template/client/src/components/AiSettingsModal.tsx +218 -4
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +110 -27
- package/template/client/src/components/ChatView.tsx +69 -4
- package/template/client/src/components/CodeContextPanel.tsx +297 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
- package/template/client/src/components/DocRefModal.tsx +502 -0
- package/template/client/src/components/FileAttachments.tsx +109 -9
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/MarkdownRenderer.tsx +205 -2
- package/template/client/src/components/Sidebar.tsx +213 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +162 -19
- package/template/client/src/store.ts +201 -0
- package/template/client/src/types.ts +8 -0
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1107 -46
- package/template/server/src/storage.ts +263 -2
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 (
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
}
|