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,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
+ }