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,763 @@
|
|
|
1
|
+
import { useChat } from "@ai-sdk/react";
|
|
2
|
+
import { DefaultChatTransport } from "ai";
|
|
3
|
+
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
|
4
|
+
import type { Question, Annotation, ReadingBookmark } from "../types";
|
|
5
|
+
import { useStore } from "../store";
|
|
6
|
+
import ChatMessage from "./ChatMessage";
|
|
7
|
+
import FileAttachments from "./FileAttachments";
|
|
8
|
+
import { Send, Loader2, Settings2, RotateCcw } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
question: Question;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ResponseLength = "normal" | "moderate" | "concise";
|
|
15
|
+
type ResponseStyle = "prose" | "bullets" | "structured";
|
|
16
|
+
type ResponseAudience = "normal" | "beginner";
|
|
17
|
+
|
|
18
|
+
interface ResponsePreferenceCache {
|
|
19
|
+
length?: ResponseLength;
|
|
20
|
+
style?: ResponseStyle;
|
|
21
|
+
audience?: ResponseAudience;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const responseLengthPrompts: Record<ResponseLength, string> = {
|
|
25
|
+
concise:
|
|
26
|
+
"Keep the response concise. Aim for roughly 300 characters of text when possible. These limits do not apply to mermaid diagrams. You can generate as many as you want to explain the solution effectively. Prioritize diagrams over text.",
|
|
27
|
+
moderate:
|
|
28
|
+
"Keep the response moderately detailed. Aim for roughly 550 characters of text when possible.",
|
|
29
|
+
normal:
|
|
30
|
+
"Use a fuller answer with enough context to explain the idea clearly.",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const responseStylePrompts: Record<ResponseStyle, string> = {
|
|
34
|
+
prose:
|
|
35
|
+
"Use natural prose with short paragraphs. Avoid bullet lists and numbered lists unless I explicitly ask for them.",
|
|
36
|
+
bullets: "Use bullet points and short lists as the main format.",
|
|
37
|
+
structured:
|
|
38
|
+
"Use structured sections with headings and numbered steps when helpful.",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const responseAudiencePrompts: Record<ResponseAudience, string> = {
|
|
42
|
+
normal: "",
|
|
43
|
+
beginner:
|
|
44
|
+
"When using technical terms or abbreviations, immediately expand their meaning in square brackets right after the term — e.g. 'TCP [Transmission Control Protocol — a connection-oriented transport protocol]' or 'idempotent [an operation that produces the same result no matter how many times it is applied]'. Do this throughout your response so I never need to look anything up.",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function findLastUserMessageIndex(messages: any[]): number {
|
|
48
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
49
|
+
if (messages[index]?.role === "user") {
|
|
50
|
+
return index;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return -1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildPreferenceSuffix(
|
|
58
|
+
cache: ResponsePreferenceCache,
|
|
59
|
+
responseLength: ResponseLength,
|
|
60
|
+
responseStyle: ResponseStyle,
|
|
61
|
+
responseAudience: ResponseAudience,
|
|
62
|
+
): string {
|
|
63
|
+
const updates: string[] = [];
|
|
64
|
+
|
|
65
|
+
if (cache.length !== responseLength) {
|
|
66
|
+
updates.push(responseLengthPrompts[responseLength]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (cache.style !== responseStyle) {
|
|
70
|
+
updates.push(responseStylePrompts[responseStyle]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (cache.audience !== responseAudience) {
|
|
74
|
+
const audiencePrompt = responseAudiencePrompts[responseAudience];
|
|
75
|
+
if (audiencePrompt) updates.push(audiencePrompt);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (updates.length === 0) {
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `\n\nUse these response preferences for this chat until I change them: ${updates.join(" ")}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function appendPreferenceSuffixToMessages(
|
|
86
|
+
messages: any[],
|
|
87
|
+
suffix: string,
|
|
88
|
+
): any[] {
|
|
89
|
+
if (!suffix) {
|
|
90
|
+
return messages;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lastUserMessageIndex = findLastUserMessageIndex(messages);
|
|
94
|
+
|
|
95
|
+
if (lastUserMessageIndex === -1) {
|
|
96
|
+
return messages;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return messages.map((message, messageIndex) => {
|
|
100
|
+
if (messageIndex !== lastUserMessageIndex) {
|
|
101
|
+
return message;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!Array.isArray(message.parts)) {
|
|
105
|
+
return {
|
|
106
|
+
...message,
|
|
107
|
+
content:
|
|
108
|
+
typeof message.content === "string"
|
|
109
|
+
? `${message.content}${suffix}`
|
|
110
|
+
: message.content,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let appended = false;
|
|
115
|
+
const nextParts = message.parts.map((part: any) => {
|
|
116
|
+
if (part?.type !== "text" || appended) {
|
|
117
|
+
return part;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
appended = true;
|
|
121
|
+
return {
|
|
122
|
+
...part,
|
|
123
|
+
text: `${part.text || ""}${suffix}`,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!appended) {
|
|
128
|
+
nextParts.push({ type: "text", text: suffix.trimStart() });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
...message,
|
|
133
|
+
content:
|
|
134
|
+
typeof message.content === "string"
|
|
135
|
+
? `${message.content}${suffix}`
|
|
136
|
+
: message.content,
|
|
137
|
+
parts: nextParts,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export default function ChatView({ question }: Props) {
|
|
143
|
+
const {
|
|
144
|
+
refreshCurrentQuestion,
|
|
145
|
+
uploadQuestionFiles,
|
|
146
|
+
removeQuestionFile,
|
|
147
|
+
clearMessages,
|
|
148
|
+
updateQuestionSystemContext,
|
|
149
|
+
topics,
|
|
150
|
+
selectedTopicId,
|
|
151
|
+
codeSnippets,
|
|
152
|
+
} = useStore();
|
|
153
|
+
const [showContext, setShowContext] = useState(false);
|
|
154
|
+
const [systemContext, setSystemContext] = useState(
|
|
155
|
+
question.systemContext || "",
|
|
156
|
+
);
|
|
157
|
+
const [input, setInput] = useState("");
|
|
158
|
+
const [responseLength, setResponseLength] =
|
|
159
|
+
useState<ResponseLength>("normal");
|
|
160
|
+
const [responseStyle, setResponseStyle] = useState<ResponseStyle>("prose");
|
|
161
|
+
const [responseAudience, setResponseAudience] =
|
|
162
|
+
useState<ResponseAudience>("normal");
|
|
163
|
+
const [alwaysSendPrefs, setAlwaysSendPrefs] = useState(false);
|
|
164
|
+
const [annotations, setAnnotations] = useState<Annotation[]>(
|
|
165
|
+
question.annotations ?? [],
|
|
166
|
+
);
|
|
167
|
+
const [readingBookmark, setReadingBookmark] = useState<
|
|
168
|
+
ReadingBookmark | undefined
|
|
169
|
+
>(question.readingBookmark);
|
|
170
|
+
const bookmarkRef = useRef<HTMLDivElement | null>(null);
|
|
171
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
172
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
173
|
+
const systemContextSaveTimeoutRef = useRef<number | null>(null);
|
|
174
|
+
const responsePreferenceCacheRef = useRef<ResponsePreferenceCache>({});
|
|
175
|
+
const pendingResponsePreferenceCacheRef =
|
|
176
|
+
useRef<ResponsePreferenceCache | null>(null);
|
|
177
|
+
const requestOptionsRef = useRef({
|
|
178
|
+
questionId: question.id,
|
|
179
|
+
topicId: question.topicId,
|
|
180
|
+
topicTitle: "",
|
|
181
|
+
questionTitle: question.title,
|
|
182
|
+
codeContextFiles: question.codeContextFiles,
|
|
183
|
+
systemContext,
|
|
184
|
+
responseLength,
|
|
185
|
+
responseStyle,
|
|
186
|
+
responseAudience,
|
|
187
|
+
alwaysSendPrefs,
|
|
188
|
+
codeSnippets,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const currentTopic = topics.find((t) => t.id === selectedTopicId);
|
|
192
|
+
const topicFileCount = currentTopic?.contextFiles?.length || 0;
|
|
193
|
+
const questionFileCount = question.contextFiles?.length || 0;
|
|
194
|
+
|
|
195
|
+
requestOptionsRef.current = {
|
|
196
|
+
questionId: question.id,
|
|
197
|
+
topicId: question.topicId,
|
|
198
|
+
topicTitle: currentTopic?.name || "",
|
|
199
|
+
questionTitle: question.title,
|
|
200
|
+
codeContextFiles: question.codeContextFiles,
|
|
201
|
+
systemContext,
|
|
202
|
+
responseLength,
|
|
203
|
+
responseStyle,
|
|
204
|
+
responseAudience,
|
|
205
|
+
alwaysSendPrefs,
|
|
206
|
+
codeSnippets,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const transport = useMemo(
|
|
210
|
+
() =>
|
|
211
|
+
new DefaultChatTransport({
|
|
212
|
+
api: "/api/chat",
|
|
213
|
+
prepareSendMessagesRequest: ({ body, messages, trigger }) => {
|
|
214
|
+
const preferenceSuffix =
|
|
215
|
+
trigger === "submit-message"
|
|
216
|
+
? buildPreferenceSuffix(
|
|
217
|
+
requestOptionsRef.current.alwaysSendPrefs
|
|
218
|
+
? {}
|
|
219
|
+
: responsePreferenceCacheRef.current,
|
|
220
|
+
requestOptionsRef.current.responseLength,
|
|
221
|
+
requestOptionsRef.current.responseStyle,
|
|
222
|
+
requestOptionsRef.current.responseAudience,
|
|
223
|
+
)
|
|
224
|
+
: "";
|
|
225
|
+
|
|
226
|
+
const outgoingMessages = appendPreferenceSuffixToMessages(
|
|
227
|
+
messages,
|
|
228
|
+
preferenceSuffix,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
pendingResponsePreferenceCacheRef.current = preferenceSuffix
|
|
232
|
+
? {
|
|
233
|
+
length: requestOptionsRef.current.responseLength,
|
|
234
|
+
style: requestOptionsRef.current.responseStyle,
|
|
235
|
+
audience: requestOptionsRef.current.responseAudience,
|
|
236
|
+
}
|
|
237
|
+
: null;
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
body: {
|
|
241
|
+
messages: outgoingMessages,
|
|
242
|
+
...(body ?? {}),
|
|
243
|
+
...requestOptionsRef.current,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
[question.id],
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Stable initial messages — only recalculated when the question itself changes,
|
|
252
|
+
// not on every render. Prevents useChat from seeing a perpetually-new array
|
|
253
|
+
// and triggering an update loop.
|
|
254
|
+
const initialMessages = useMemo(
|
|
255
|
+
() =>
|
|
256
|
+
question.messages.map((m) => ({
|
|
257
|
+
id: m.id,
|
|
258
|
+
role: m.role as "user" | "assistant",
|
|
259
|
+
parts: [{ type: "text" as const, text: m.content }],
|
|
260
|
+
})),
|
|
261
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
262
|
+
[question.id],
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const handleChatError = useCallback((err: Error) => {
|
|
266
|
+
pendingResponsePreferenceCacheRef.current = null;
|
|
267
|
+
console.error("Chat error:", err);
|
|
268
|
+
}, []);
|
|
269
|
+
|
|
270
|
+
const handleChatFinish = useCallback(() => {
|
|
271
|
+
if (pendingResponsePreferenceCacheRef.current) {
|
|
272
|
+
responsePreferenceCacheRef.current =
|
|
273
|
+
pendingResponsePreferenceCacheRef.current;
|
|
274
|
+
pendingResponsePreferenceCacheRef.current = null;
|
|
275
|
+
}
|
|
276
|
+
refreshCurrentQuestion();
|
|
277
|
+
}, [refreshCurrentQuestion]);
|
|
278
|
+
|
|
279
|
+
const { messages, setMessages, sendMessage, status, error } = useChat({
|
|
280
|
+
id: question.id,
|
|
281
|
+
transport,
|
|
282
|
+
messages: initialMessages,
|
|
283
|
+
onError: handleChatError,
|
|
284
|
+
onFinish: handleChatFinish,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const isLoading = status === "streaming" || status === "submitted";
|
|
288
|
+
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
291
|
+
}, [messages]);
|
|
292
|
+
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (systemContext === question.systemContext) return;
|
|
295
|
+
|
|
296
|
+
if (systemContextSaveTimeoutRef.current) {
|
|
297
|
+
window.clearTimeout(systemContextSaveTimeoutRef.current);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
systemContextSaveTimeoutRef.current = window.setTimeout(() => {
|
|
301
|
+
systemContextSaveTimeoutRef.current = null;
|
|
302
|
+
updateQuestionSystemContext(question.id, systemContext).catch((err) => {
|
|
303
|
+
console.error("Failed to save system context:", err);
|
|
304
|
+
});
|
|
305
|
+
}, 400);
|
|
306
|
+
|
|
307
|
+
return () => {
|
|
308
|
+
if (systemContextSaveTimeoutRef.current) {
|
|
309
|
+
window.clearTimeout(systemContextSaveTimeoutRef.current);
|
|
310
|
+
systemContextSaveTimeoutRef.current = null;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}, [
|
|
314
|
+
question.id,
|
|
315
|
+
question.systemContext,
|
|
316
|
+
systemContext,
|
|
317
|
+
updateQuestionSystemContext,
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
// Reset annotations and bookmark when switching questions
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
setAnnotations(question.annotations ?? []);
|
|
323
|
+
setReadingBookmark(question.readingBookmark);
|
|
324
|
+
}, [question.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
325
|
+
|
|
326
|
+
const handleSetBookmark = useCallback(
|
|
327
|
+
(messageId: string, blockIndex: number) => {
|
|
328
|
+
const isSame =
|
|
329
|
+
readingBookmark?.messageId === messageId &&
|
|
330
|
+
readingBookmark?.blockIndex === blockIndex;
|
|
331
|
+
const next = isSame ? undefined : { messageId, blockIndex };
|
|
332
|
+
setReadingBookmark(next);
|
|
333
|
+
fetch(`/api/questions/${question.id}`, {
|
|
334
|
+
method: "PATCH",
|
|
335
|
+
headers: { "Content-Type": "application/json" },
|
|
336
|
+
body: JSON.stringify({ readingBookmark: next ?? null }),
|
|
337
|
+
}).catch((err) => console.error("Failed to save bookmark:", err));
|
|
338
|
+
},
|
|
339
|
+
[question.id, readingBookmark],
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const handleAnnotationCreate = useCallback(
|
|
343
|
+
(annotation: Annotation) => {
|
|
344
|
+
setAnnotations((prev) => {
|
|
345
|
+
const next = [...prev, annotation];
|
|
346
|
+
fetch(`/api/questions/${question.id}`, {
|
|
347
|
+
method: "PATCH",
|
|
348
|
+
headers: { "Content-Type": "application/json" },
|
|
349
|
+
body: JSON.stringify({ annotations: next }),
|
|
350
|
+
}).catch((err) => console.error("Failed to save annotation:", err));
|
|
351
|
+
return next;
|
|
352
|
+
});
|
|
353
|
+
},
|
|
354
|
+
[question.id],
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const handleAnnotationUpdate = useCallback(
|
|
358
|
+
(updated: Annotation) => {
|
|
359
|
+
setAnnotations((prev) => {
|
|
360
|
+
const next = prev.map((a) => (a.id === updated.id ? updated : a));
|
|
361
|
+
fetch(`/api/questions/${question.id}`, {
|
|
362
|
+
method: "PATCH",
|
|
363
|
+
headers: { "Content-Type": "application/json" },
|
|
364
|
+
body: JSON.stringify({ annotations: next }),
|
|
365
|
+
}).catch((err) => console.error("Failed to save annotation:", err));
|
|
366
|
+
return next;
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
[question.id],
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
// Group annotations by message id so we don't run filter() inside render,
|
|
373
|
+
// which would produce a new array reference on every ChatView re-render and
|
|
374
|
+
// defeat React.memo on ChatMessage.
|
|
375
|
+
const annotationsByMessageId = useMemo(() => {
|
|
376
|
+
const map: Record<string, Annotation[]> = {};
|
|
377
|
+
for (const ann of annotations) {
|
|
378
|
+
if (!map[ann.messageId]) map[ann.messageId] = [];
|
|
379
|
+
map[ann.messageId].push(ann);
|
|
380
|
+
}
|
|
381
|
+
return map;
|
|
382
|
+
}, [annotations]);
|
|
383
|
+
const EMPTY_ANNOTATIONS: Annotation[] = useMemo(() => [], []);
|
|
384
|
+
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
const el = textareaRef.current;
|
|
387
|
+
if (!el) return;
|
|
388
|
+
el.style.height = "auto";
|
|
389
|
+
el.style.height = `${Math.min(el.scrollHeight, 128)}px`;
|
|
390
|
+
}, [input]);
|
|
391
|
+
|
|
392
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
393
|
+
e.preventDefault();
|
|
394
|
+
if (!input.trim() || isLoading) return;
|
|
395
|
+
const text = input;
|
|
396
|
+
setInput("");
|
|
397
|
+
sendMessage({ text });
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<div className="flex flex-col h-full">
|
|
402
|
+
{/* System context toggle */}
|
|
403
|
+
<div className="border-b border-slate-800 px-4 py-2 shrink-0">
|
|
404
|
+
<div className="flex items-center justify-between">
|
|
405
|
+
<button
|
|
406
|
+
onClick={() => setShowContext(!showContext)}
|
|
407
|
+
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
|
408
|
+
>
|
|
409
|
+
<Settings2 className="w-3 h-3" />
|
|
410
|
+
System Context
|
|
411
|
+
{systemContext && (
|
|
412
|
+
<span className="w-1.5 h-1.5 rounded-full bg-cyan-500" />
|
|
413
|
+
)}
|
|
414
|
+
</button>
|
|
415
|
+
{messages.length > 0 && (
|
|
416
|
+
<button
|
|
417
|
+
onClick={async () => {
|
|
418
|
+
if (
|
|
419
|
+
!window.confirm(
|
|
420
|
+
"Clear all messages for this question? Files and context will be kept.",
|
|
421
|
+
)
|
|
422
|
+
) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
await clearMessages(question.id);
|
|
427
|
+
responsePreferenceCacheRef.current = {};
|
|
428
|
+
pendingResponsePreferenceCacheRef.current = null;
|
|
429
|
+
setMessages([]);
|
|
430
|
+
}}
|
|
431
|
+
disabled={isLoading}
|
|
432
|
+
className="flex items-center gap-1 text-[11px] text-slate-500 hover:text-red-400 disabled:opacity-50 transition-colors"
|
|
433
|
+
>
|
|
434
|
+
<RotateCcw className="w-3 h-3" />
|
|
435
|
+
Clear chat
|
|
436
|
+
</button>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
{showContext && (
|
|
440
|
+
<textarea
|
|
441
|
+
value={systemContext}
|
|
442
|
+
onChange={(e) => setSystemContext(e.target.value)}
|
|
443
|
+
onBlur={() => {
|
|
444
|
+
if (systemContextSaveTimeoutRef.current) {
|
|
445
|
+
window.clearTimeout(systemContextSaveTimeoutRef.current);
|
|
446
|
+
systemContextSaveTimeoutRef.current = null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (systemContext === question.systemContext) return;
|
|
450
|
+
|
|
451
|
+
updateQuestionSystemContext(question.id, systemContext).catch(
|
|
452
|
+
(err) => {
|
|
453
|
+
console.error("Failed to save system context:", err);
|
|
454
|
+
},
|
|
455
|
+
);
|
|
456
|
+
}}
|
|
457
|
+
placeholder="Add context about the role, project, or specific requirements..."
|
|
458
|
+
className="mt-2 w-full bg-slate-800/50 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500 resize-none"
|
|
459
|
+
rows={3}
|
|
460
|
+
/>
|
|
461
|
+
)}
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
{/* Sticky bookmark banner — sits outside the scroll container */}
|
|
465
|
+
{readingBookmark &&
|
|
466
|
+
messages.some((m) => m.id === readingBookmark.messageId) && (
|
|
467
|
+
<div className="shrink-0 px-4 pt-2">
|
|
468
|
+
<button
|
|
469
|
+
onClick={() => {
|
|
470
|
+
const el = document.querySelector<HTMLElement>(
|
|
471
|
+
'[data-reading-bookmark="true"]',
|
|
472
|
+
);
|
|
473
|
+
(el ?? bookmarkRef.current)?.scrollIntoView({
|
|
474
|
+
behavior: "smooth",
|
|
475
|
+
block: "center",
|
|
476
|
+
});
|
|
477
|
+
}}
|
|
478
|
+
className="w-full flex items-center gap-1.5 justify-center text-[11px] text-amber-400/80 hover:text-amber-300 bg-amber-500/5 hover:bg-amber-500/10 border border-amber-500/20 rounded-lg py-1.5 transition-colors"
|
|
479
|
+
>
|
|
480
|
+
<span>↓</span> Resume reading from bookmark
|
|
481
|
+
</button>
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
|
|
485
|
+
{/* Messages */}
|
|
486
|
+
<div
|
|
487
|
+
id="chat-scroll-area"
|
|
488
|
+
className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
|
|
489
|
+
>
|
|
490
|
+
{messages.length === 0 && (
|
|
491
|
+
<div className="flex items-center justify-center h-full">
|
|
492
|
+
<div className="text-center">
|
|
493
|
+
<p className="text-sm text-slate-500">No messages yet</p>
|
|
494
|
+
<p className="text-xs text-slate-600 mt-1">
|
|
495
|
+
Ask about this topic and get explanations with code & diagrams
|
|
496
|
+
</p>
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
)}
|
|
500
|
+
{messages.map((message) => (
|
|
501
|
+
<div
|
|
502
|
+
key={message.id}
|
|
503
|
+
ref={message.id === readingBookmark?.messageId ? bookmarkRef : null}
|
|
504
|
+
>
|
|
505
|
+
<ChatMessage
|
|
506
|
+
message={message}
|
|
507
|
+
annotations={
|
|
508
|
+
annotationsByMessageId[message.id] ?? EMPTY_ANNOTATIONS
|
|
509
|
+
}
|
|
510
|
+
onAnnotationCreate={handleAnnotationCreate}
|
|
511
|
+
onAnnotationUpdate={handleAnnotationUpdate}
|
|
512
|
+
bookmarkedBlockIndex={
|
|
513
|
+
message.id === readingBookmark?.messageId
|
|
514
|
+
? readingBookmark?.blockIndex
|
|
515
|
+
: undefined
|
|
516
|
+
}
|
|
517
|
+
onSetBookmark={handleSetBookmark}
|
|
518
|
+
responseLength={responseLength}
|
|
519
|
+
responseStyle={responseStyle}
|
|
520
|
+
responseAudience={responseAudience}
|
|
521
|
+
/>
|
|
522
|
+
</div>
|
|
523
|
+
))}
|
|
524
|
+
{status === "submitted" && (
|
|
525
|
+
<div className="flex items-start gap-3 px-1">
|
|
526
|
+
<div className="w-7 h-7 rounded-lg bg-cyan-600/20 flex items-center justify-center shrink-0 mt-0.5">
|
|
527
|
+
<Loader2 className="w-3.5 h-3.5 text-cyan-400 animate-spin" />
|
|
528
|
+
</div>
|
|
529
|
+
<div className="flex items-center gap-1.5 py-2">
|
|
530
|
+
<span className="text-sm text-slate-400">Thinking</span>
|
|
531
|
+
<span className="flex gap-0.5">
|
|
532
|
+
<span
|
|
533
|
+
className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
|
|
534
|
+
style={{ animationDelay: "0ms" }}
|
|
535
|
+
/>
|
|
536
|
+
<span
|
|
537
|
+
className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
|
|
538
|
+
style={{ animationDelay: "150ms" }}
|
|
539
|
+
/>
|
|
540
|
+
<span
|
|
541
|
+
className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
|
|
542
|
+
style={{ animationDelay: "300ms" }}
|
|
543
|
+
/>
|
|
544
|
+
</span>
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
)}
|
|
548
|
+
{status === "error" && (
|
|
549
|
+
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-sm text-red-400">
|
|
550
|
+
{error?.message ||
|
|
551
|
+
"An error occurred. Check your API key and try again."}
|
|
552
|
+
</div>
|
|
553
|
+
)}
|
|
554
|
+
<div ref={messagesEndRef} />
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
{/* Input */}
|
|
558
|
+
<div className="border-t border-slate-800 px-4 py-3 shrink-0">
|
|
559
|
+
{/* Question-level file attachments */}
|
|
560
|
+
<div className="mb-2">
|
|
561
|
+
<FileAttachments
|
|
562
|
+
files={question.contextFiles || []}
|
|
563
|
+
onUpload={(files) => uploadQuestionFiles(question.id, files)}
|
|
564
|
+
onRemove={(fileId) => removeQuestionFile(question.id, fileId)}
|
|
565
|
+
label="question"
|
|
566
|
+
compact
|
|
567
|
+
/>
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
<form onSubmit={handleSubmit}>
|
|
571
|
+
<div className="flex gap-2">
|
|
572
|
+
<textarea
|
|
573
|
+
ref={textareaRef}
|
|
574
|
+
value={input}
|
|
575
|
+
onChange={(e) => setInput(e.target.value)}
|
|
576
|
+
onKeyDown={(e) => {
|
|
577
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
578
|
+
e.preventDefault();
|
|
579
|
+
if (!input.trim() || isLoading) return;
|
|
580
|
+
handleSubmit(e as any);
|
|
581
|
+
}
|
|
582
|
+
}}
|
|
583
|
+
placeholder="Ask about this topic..."
|
|
584
|
+
rows={1}
|
|
585
|
+
className="flex-1 bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-500 transition-colors resize-none overflow-y-auto"
|
|
586
|
+
style={{ minHeight: "2.625rem", maxHeight: "8rem" }}
|
|
587
|
+
disabled={isLoading}
|
|
588
|
+
/>
|
|
589
|
+
<button
|
|
590
|
+
type="submit"
|
|
591
|
+
disabled={isLoading || !input.trim()}
|
|
592
|
+
className="px-4 py-2.5 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded-lg transition-colors flex items-center gap-2"
|
|
593
|
+
>
|
|
594
|
+
{isLoading ? (
|
|
595
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
596
|
+
) : (
|
|
597
|
+
<Send className="w-4 h-4" />
|
|
598
|
+
)}
|
|
599
|
+
</button>
|
|
600
|
+
</div>
|
|
601
|
+
</form>
|
|
602
|
+
|
|
603
|
+
{/* Response controls */}
|
|
604
|
+
<div className="flex items-center gap-4 mt-2">
|
|
605
|
+
<div className="flex items-center gap-1.5">
|
|
606
|
+
<span className="text-[10px] text-slate-500 uppercase tracking-wider">
|
|
607
|
+
Length
|
|
608
|
+
</span>
|
|
609
|
+
{(["normal", "moderate", "concise"] as const).map((opt) => (
|
|
610
|
+
<button
|
|
611
|
+
key={opt}
|
|
612
|
+
onClick={() => setResponseLength(opt)}
|
|
613
|
+
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
|
|
614
|
+
responseLength === opt
|
|
615
|
+
? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
|
|
616
|
+
: "text-slate-500 hover:text-slate-300 border border-transparent"
|
|
617
|
+
}`}
|
|
618
|
+
>
|
|
619
|
+
{opt.charAt(0).toUpperCase() + opt.slice(1)}
|
|
620
|
+
</button>
|
|
621
|
+
))}
|
|
622
|
+
</div>
|
|
623
|
+
<div className="w-px h-4 bg-slate-700" />
|
|
624
|
+
<div className="flex items-center gap-1.5">
|
|
625
|
+
<span className="text-[10px] text-slate-500 uppercase tracking-wider">
|
|
626
|
+
Style
|
|
627
|
+
</span>
|
|
628
|
+
{(
|
|
629
|
+
[
|
|
630
|
+
["prose", "Prose"],
|
|
631
|
+
["bullets", "Bullets"],
|
|
632
|
+
["structured", "Structured"],
|
|
633
|
+
] as const
|
|
634
|
+
).map(([key, label]) => (
|
|
635
|
+
<button
|
|
636
|
+
key={key}
|
|
637
|
+
onClick={() => setResponseStyle(key)}
|
|
638
|
+
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
|
|
639
|
+
responseStyle === key
|
|
640
|
+
? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
|
|
641
|
+
: "text-slate-500 hover:text-slate-300 border border-transparent"
|
|
642
|
+
}`}
|
|
643
|
+
>
|
|
644
|
+
{label}
|
|
645
|
+
</button>
|
|
646
|
+
))}
|
|
647
|
+
</div>
|
|
648
|
+
<div className="w-px h-4 bg-slate-700" />
|
|
649
|
+
<div className="flex items-center gap-1.5">
|
|
650
|
+
<span className="text-[10px] text-slate-500 uppercase tracking-wider">
|
|
651
|
+
Mode
|
|
652
|
+
</span>
|
|
653
|
+
{(["normal", "beginner"] as const).map((opt) => (
|
|
654
|
+
<button
|
|
655
|
+
key={opt}
|
|
656
|
+
onClick={() => setResponseAudience(opt)}
|
|
657
|
+
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
|
|
658
|
+
responseAudience === opt
|
|
659
|
+
? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
|
|
660
|
+
: "text-slate-500 hover:text-slate-300 border border-transparent"
|
|
661
|
+
}`}
|
|
662
|
+
>
|
|
663
|
+
{opt.charAt(0).toUpperCase() + opt.slice(1)}
|
|
664
|
+
</button>
|
|
665
|
+
))}
|
|
666
|
+
</div>
|
|
667
|
+
<div className="w-px h-4 bg-slate-700" />
|
|
668
|
+
<button
|
|
669
|
+
onClick={() => setAlwaysSendPrefs((p) => !p)}
|
|
670
|
+
title={
|
|
671
|
+
alwaysSendPrefs
|
|
672
|
+
? "Preferences sent with every message"
|
|
673
|
+
: "Preferences sent only when changed"
|
|
674
|
+
}
|
|
675
|
+
className={`flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-md border transition-colors ${
|
|
676
|
+
alwaysSendPrefs
|
|
677
|
+
? "bg-amber-600/20 text-amber-300 border-amber-600/40"
|
|
678
|
+
: "text-slate-500 hover:text-slate-300 border-transparent"
|
|
679
|
+
}`}
|
|
680
|
+
>
|
|
681
|
+
<span
|
|
682
|
+
className={`inline-block w-1.5 h-1.5 rounded-full ${
|
|
683
|
+
alwaysSendPrefs ? "bg-amber-400" : "bg-slate-600"
|
|
684
|
+
}`}
|
|
685
|
+
/>
|
|
686
|
+
Prefs: {alwaysSendPrefs ? "always" : "on change"}
|
|
687
|
+
</button>
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
{/* Context summary */}
|
|
691
|
+
{(topicFileCount > 0 ||
|
|
692
|
+
questionFileCount > 0 ||
|
|
693
|
+
question.codeContextFiles.length > 0) && (
|
|
694
|
+
<ContextSummary
|
|
695
|
+
topicFileCount={topicFileCount}
|
|
696
|
+
questionFileCount={questionFileCount}
|
|
697
|
+
codeContextFiles={question.codeContextFiles}
|
|
698
|
+
/>
|
|
699
|
+
)}
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function ContextSummary({
|
|
706
|
+
topicFileCount,
|
|
707
|
+
questionFileCount,
|
|
708
|
+
codeContextFiles,
|
|
709
|
+
}: {
|
|
710
|
+
topicFileCount: number;
|
|
711
|
+
questionFileCount: number;
|
|
712
|
+
codeContextFiles: string[];
|
|
713
|
+
}) {
|
|
714
|
+
const { openFileViewer } = useStore();
|
|
715
|
+
const [expanded, setExpanded] = useState(false);
|
|
716
|
+
const VISIBLE_COUNT = 2;
|
|
717
|
+
const visibleFiles = expanded
|
|
718
|
+
? codeContextFiles
|
|
719
|
+
: codeContextFiles.slice(0, VISIBLE_COUNT);
|
|
720
|
+
const hiddenCount = codeContextFiles.length - VISIBLE_COUNT;
|
|
721
|
+
|
|
722
|
+
return (
|
|
723
|
+
<div className="mt-1.5 flex items-center gap-2 flex-wrap text-[10px] text-slate-600">
|
|
724
|
+
{topicFileCount > 0 && (
|
|
725
|
+
<span className="bg-violet-500/10 text-violet-400 px-1.5 py-0.5 rounded">
|
|
726
|
+
{topicFileCount} topic file{topicFileCount > 1 ? "s" : ""}
|
|
727
|
+
</span>
|
|
728
|
+
)}
|
|
729
|
+
{questionFileCount > 0 && (
|
|
730
|
+
<span className="bg-violet-500/10 text-violet-400 px-1.5 py-0.5 rounded">
|
|
731
|
+
{questionFileCount} question file
|
|
732
|
+
{questionFileCount > 1 ? "s" : ""}
|
|
733
|
+
</span>
|
|
734
|
+
)}
|
|
735
|
+
{visibleFiles.map((f) => (
|
|
736
|
+
<button
|
|
737
|
+
key={f}
|
|
738
|
+
onClick={() => openFileViewer(f)}
|
|
739
|
+
title={f}
|
|
740
|
+
className="bg-slate-800 hover:bg-slate-700 hover:text-slate-300 px-1.5 py-0.5 rounded transition-colors cursor-pointer"
|
|
741
|
+
>
|
|
742
|
+
{f.split("/").pop()}
|
|
743
|
+
</button>
|
|
744
|
+
))}
|
|
745
|
+
{!expanded && hiddenCount > 0 && (
|
|
746
|
+
<button
|
|
747
|
+
onClick={() => setExpanded(true)}
|
|
748
|
+
className="bg-slate-800 px-1.5 py-0.5 rounded text-cyan-400 hover:text-cyan-300 transition-colors"
|
|
749
|
+
>
|
|
750
|
+
+{hiddenCount} more
|
|
751
|
+
</button>
|
|
752
|
+
)}
|
|
753
|
+
{expanded && hiddenCount > 0 && (
|
|
754
|
+
<button
|
|
755
|
+
onClick={() => setExpanded(false)}
|
|
756
|
+
className="bg-slate-800 px-1.5 py-0.5 rounded text-cyan-400 hover:text-cyan-300 transition-colors"
|
|
757
|
+
>
|
|
758
|
+
show less
|
|
759
|
+
</button>
|
|
760
|
+
)}
|
|
761
|
+
</div>
|
|
762
|
+
);
|
|
763
|
+
}
|