create-interview-cockpit 0.4.0 → 0.6.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 (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +753 -1
  3. package/template/client/package.json +4 -0
  4. package/template/client/src/App.tsx +20 -0
  5. package/template/client/src/api.ts +455 -3
  6. package/template/client/src/components/AiSettingsModal.tsx +855 -248
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +132 -27
  9. package/template/client/src/components/ChatView.tsx +365 -123
  10. package/template/client/src/components/CodeContextPanel.tsx +714 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
  13. package/template/client/src/components/DocRefModal.tsx +551 -0
  14. package/template/client/src/components/FileAttachments.tsx +128 -12
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  18. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  19. package/template/client/src/components/MarkdownRenderer.tsx +219 -2
  20. package/template/client/src/components/NotesModal.tsx +977 -0
  21. package/template/client/src/components/PlotEmbed.tsx +173 -0
  22. package/template/client/src/components/Sidebar.tsx +397 -127
  23. package/template/client/src/components/TextAnnotator.tsx +8 -15
  24. package/template/client/src/components/VizCraftEmbed.tsx +412 -25
  25. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  26. package/template/client/src/infraLab.ts +124 -0
  27. package/template/client/src/reactLab.ts +477 -0
  28. package/template/client/src/store.ts +416 -2
  29. package/template/client/src/types.ts +41 -1
  30. package/template/client/tsconfig.tsbuildinfo +1 -1
  31. package/template/cockpit.json +1 -1
  32. package/template/package.json +1 -1
  33. package/template/server/src/google-drive.ts +144 -2
  34. package/template/server/src/index.ts +1890 -188
  35. package/template/server/src/infra-runner.ts +1104 -0
  36. package/template/server/src/storage.ts +274 -3
@@ -8,6 +8,7 @@ import {
8
8
  useMemo,
9
9
  useCallback,
10
10
  Fragment,
11
+ memo,
11
12
  } from "react";
12
13
  import type { Question, Annotation, ReadingBookmark } from "../types";
13
14
  import { useStore } from "../store";
@@ -22,7 +23,9 @@ import {
22
23
  X,
23
24
  AlertTriangle,
24
25
  ChevronRight,
26
+ Link,
25
27
  } from "lucide-react";
28
+ import LinkedConvosPicker from "./LinkedConvosPicker";
26
29
 
27
30
  interface Props {
28
31
  question: Question;
@@ -118,19 +121,196 @@ function appendPreferenceSuffixToMessages(
118
121
  });
119
122
  }
120
123
 
124
+ interface ChatTranscriptProps {
125
+ messages: UIMessage[];
126
+ readingBookmark?: ReadingBookmark;
127
+ hasBookmarkedMessage: boolean;
128
+ bookmarkRef: { current: HTMLDivElement | null };
129
+ messagesEndRef: { current: HTMLDivElement | null };
130
+ onScroll: (e: React.UIEvent<HTMLDivElement>) => void;
131
+ annotationsByMessageId: Record<string, Annotation[]>;
132
+ emptyAnnotations: Annotation[];
133
+ onAnnotationCreate: (annotation: Annotation) => void;
134
+ onAnnotationUpdate: (annotation: Annotation) => void;
135
+ onSetBookmark: (messageId: string, blockIndex: number) => void;
136
+ preferenceSuffix: string;
137
+ onSpecRefined: (
138
+ messageId: string,
139
+ originalSpec: string,
140
+ newSpec: string,
141
+ ) => void;
142
+ onDeleteMessage: (messageId: string) => void;
143
+ isLoading: boolean;
144
+ isStalled: boolean;
145
+ status: string;
146
+ lastResponseLooksTruncated: boolean;
147
+ onContinue: () => void;
148
+ error?: Error;
149
+ }
150
+
151
+ const ChatTranscript = memo(function ChatTranscript({
152
+ messages,
153
+ readingBookmark,
154
+ hasBookmarkedMessage,
155
+ bookmarkRef,
156
+ messagesEndRef,
157
+ onScroll,
158
+ annotationsByMessageId,
159
+ emptyAnnotations,
160
+ onAnnotationCreate,
161
+ onAnnotationUpdate,
162
+ onSetBookmark,
163
+ preferenceSuffix,
164
+ onSpecRefined,
165
+ onDeleteMessage,
166
+ isLoading,
167
+ isStalled,
168
+ status,
169
+ lastResponseLooksTruncated,
170
+ onContinue,
171
+ error,
172
+ }: ChatTranscriptProps) {
173
+ return (
174
+ <>
175
+ {hasBookmarkedMessage && (
176
+ <div className="shrink-0 px-4 pt-2">
177
+ <button
178
+ onClick={() => {
179
+ const el = document.querySelector<HTMLElement>(
180
+ '[data-reading-bookmark="true"]',
181
+ );
182
+ (el ?? bookmarkRef.current)?.scrollIntoView({
183
+ behavior: "smooth",
184
+ block: "center",
185
+ });
186
+ }}
187
+ 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"
188
+ >
189
+ <span>↓</span> Resume reading from bookmark
190
+ </button>
191
+ </div>
192
+ )}
193
+
194
+ <div
195
+ id="chat-scroll-area"
196
+ onScroll={onScroll}
197
+ className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
198
+ >
199
+ {messages.length === 0 && (
200
+ <div className="flex items-center justify-center h-full">
201
+ <div className="text-center">
202
+ <p className="text-sm text-slate-500">No messages yet</p>
203
+ <p className="text-xs text-slate-600 mt-1">
204
+ Ask about this topic and get explanations with code & diagrams
205
+ </p>
206
+ </div>
207
+ </div>
208
+ )}
209
+ {messages.map((message) => (
210
+ <div
211
+ key={message.id}
212
+ ref={message.id === readingBookmark?.messageId ? bookmarkRef : null}
213
+ >
214
+ <ChatMessage
215
+ message={message}
216
+ annotations={
217
+ annotationsByMessageId[message.id] ?? emptyAnnotations
218
+ }
219
+ onAnnotationCreate={onAnnotationCreate}
220
+ onAnnotationUpdate={onAnnotationUpdate}
221
+ bookmarkedBlockIndex={
222
+ message.id === readingBookmark?.messageId
223
+ ? readingBookmark?.blockIndex
224
+ : undefined
225
+ }
226
+ onSetBookmark={onSetBookmark}
227
+ preferenceSuffix={preferenceSuffix}
228
+ onSpecRefined={onSpecRefined}
229
+ onDeleteMessage={!isLoading ? onDeleteMessage : undefined}
230
+ />
231
+ </div>
232
+ ))}
233
+
234
+ {isStalled && status === "streaming" && (
235
+ <div className="flex items-center gap-3 bg-amber-500/10 border border-amber-500/20 rounded-lg px-4 py-3 text-sm">
236
+ <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
237
+ <span className="text-amber-300 flex-1">
238
+ Response appears to have stalled.
239
+ </span>
240
+ <button
241
+ onClick={onContinue}
242
+ className="flex items-center gap-1 text-xs text-amber-400 hover:text-amber-200 border border-amber-500/30 hover:border-amber-400/60 rounded-md px-2.5 py-1 transition-colors"
243
+ >
244
+ Continue <ChevronRight className="w-3 h-3" />
245
+ </button>
246
+ </div>
247
+ )}
248
+
249
+ {status === "ready" && lastResponseLooksTruncated && (
250
+ <div className="flex justify-start pl-10">
251
+ <button
252
+ onClick={onContinue}
253
+ className="flex items-center gap-1 text-[11px] text-slate-500 hover:text-cyan-400 border border-slate-700/50 hover:border-cyan-500/40 rounded-md px-2.5 py-1 transition-colors"
254
+ title="Ask the model to continue from where it left off"
255
+ >
256
+ <ChevronRight className="w-3 h-3" /> Continue
257
+ </button>
258
+ </div>
259
+ )}
260
+ {status === "submitted" && (
261
+ <div className="flex items-start gap-3 px-1">
262
+ <div className="w-7 h-7 rounded-lg bg-cyan-600/20 flex items-center justify-center shrink-0 mt-0.5">
263
+ <Loader2 className="w-3.5 h-3.5 text-cyan-400 animate-spin" />
264
+ </div>
265
+ <div className="flex items-center gap-1.5 py-2">
266
+ <span className="text-sm text-slate-400">Thinking</span>
267
+ <span className="flex gap-0.5">
268
+ <span
269
+ className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
270
+ style={{ animationDelay: "0ms" }}
271
+ />
272
+ <span
273
+ className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
274
+ style={{ animationDelay: "150ms" }}
275
+ />
276
+ <span
277
+ className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
278
+ style={{ animationDelay: "300ms" }}
279
+ />
280
+ </span>
281
+ </div>
282
+ </div>
283
+ )}
284
+ {status === "error" && (
285
+ <div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-sm text-red-400">
286
+ {error?.message ||
287
+ "An error occurred. Check your API key and try again."}
288
+ </div>
289
+ )}
290
+ <div ref={messagesEndRef} />
291
+ </div>
292
+ </>
293
+ );
294
+ });
295
+
121
296
  export default function ChatView({ question }: Props) {
122
297
  const {
123
298
  refreshCurrentQuestion,
124
299
  uploadQuestionFiles,
125
300
  removeQuestionFile,
301
+ linkFileToQuestion,
126
302
  clearMessages,
127
303
  updateQuestionSystemContext,
128
304
  topics,
129
305
  selectedTopicId,
130
306
  codeSnippets,
131
307
  aiSettings,
308
+ setLivePreferenceSuffix,
309
+ linkConversation,
310
+ unlinkConversation,
132
311
  } = useStore();
133
312
  const [showContext, setShowContext] = useState(false);
313
+ const [showLinkedPicker, setShowLinkedPicker] = useState(false);
134
314
  const [systemContext, setSystemContext] = useState(
135
315
  question.systemContext || "",
136
316
  );
@@ -142,7 +322,9 @@ export default function ChatView({ question }: Props) {
142
322
  Object.entries(aiSettings.promptGroups).map(([k, g]) => [k, g.default]),
143
323
  ),
144
324
  );
145
- const [alwaysSendPrefs, setAlwaysSendPrefs] = useState(false);
325
+ const [alwaysSendPrefs, setAlwaysSendPrefs] = useState(
326
+ () => aiSettings.alwaysSendPrefsDefault ?? false,
327
+ );
146
328
  const [annotations, setAnnotations] = useState<Annotation[]>(
147
329
  question.annotations ?? [],
148
330
  );
@@ -151,6 +333,11 @@ export default function ChatView({ question }: Props) {
151
333
  >(question.readingBookmark);
152
334
  const bookmarkRef = useRef<HTMLDivElement | null>(null);
153
335
  const messagesEndRef = useRef<HTMLDivElement>(null);
336
+ // Whether the user is pinned to the bottom of the scroll area.
337
+ // True by default; becomes false when user scrolls up; reset on new message.
338
+ const stickToBottomRef = useRef(true);
339
+ // rAF handle so we schedule at most one scroll per animation frame during streaming.
340
+ const scrollRafRef = useRef<number | null>(null);
154
341
  const textareaRef = useRef<HTMLTextAreaElement>(null);
155
342
  const imageInputRef = useRef<HTMLInputElement>(null);
156
343
  const systemContextSaveTimeoutRef = useRef<number | null>(null);
@@ -224,6 +411,7 @@ export default function ChatView({ question }: Props) {
224
411
  groupSelections,
225
412
  alwaysSendPrefs,
226
413
  codeSnippets,
414
+ linkedConversationIds: question.linkedConversationIds ?? [],
227
415
  });
228
416
 
229
417
  const currentTopic = topics.find((t) => t.id === selectedTopicId);
@@ -241,6 +429,7 @@ export default function ChatView({ question }: Props) {
241
429
  groupSelections,
242
430
  alwaysSendPrefs,
243
431
  codeSnippets,
432
+ linkedConversationIds: question.linkedConversationIds ?? [],
244
433
  };
245
434
  aiSettingsRef.current = aiSettings;
246
435
 
@@ -375,9 +564,26 @@ export default function ChatView({ question }: Props) {
375
564
  return !/[.!?`\])>]$/.test(lastLine);
376
565
  }, [messages]);
377
566
 
567
+ // Smooth scroll to bottom whenever a NEW message is added (length changes).
568
+ // Using messages.length instead of the full messages array avoids firing on
569
+ // every token update during streaming.
378
570
  useEffect(() => {
571
+ stickToBottomRef.current = true;
379
572
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
380
- }, [messages]);
573
+ }, [messages.length]); // eslint-disable-line react-hooks/exhaustive-deps
574
+
575
+ // During streaming: rAF-throttled instant scroll — coalesces many per-token
576
+ // renders into at most one scroll per animation frame, and avoids restarting a
577
+ // smooth-scroll animation on every token (which causes jank + layout thrash).
578
+ useEffect(() => {
579
+ if (status !== "streaming" || !stickToBottomRef.current) return;
580
+ if (scrollRafRef.current !== null) return; // already scheduled this frame
581
+ scrollRafRef.current = requestAnimationFrame(() => {
582
+ scrollRafRef.current = null;
583
+ const el = document.getElementById("chat-scroll-area");
584
+ if (el) el.scrollTop = el.scrollHeight;
585
+ });
586
+ }, [messages, status]); // eslint-disable-line react-hooks/exhaustive-deps
381
587
 
382
588
  useEffect(() => {
383
589
  if (systemContext === question.systemContext) return;
@@ -458,6 +664,94 @@ export default function ChatView({ question }: Props) {
458
664
  [question.id],
459
665
  );
460
666
 
667
+ const handleDeleteMessage = useCallback(
668
+ async (messageId: string) => {
669
+ // Use functional update so we don't close over `messages` — keeping this
670
+ // callback stable across streaming token updates is critical: if it changed
671
+ // on every token, React.memo on ChatMessage would be bypassed for ALL N
672
+ // messages simultaneously, making long chats extremely slow.
673
+ setMessages((prev) => {
674
+ const filtered = prev.filter((m) => m.id !== messageId);
675
+ const serverMessages = filtered.map((m) => ({
676
+ id: m.id,
677
+ role: m.role,
678
+ content: (m.parts ?? [])
679
+ .filter(
680
+ (p): p is { type: "text"; text: string } => p.type === "text",
681
+ )
682
+ .map((p) => p.text)
683
+ .join(""),
684
+ parts: m.parts,
685
+ }));
686
+ fetch(`/api/questions/${question.id}`, {
687
+ method: "PATCH",
688
+ headers: { "Content-Type": "application/json" },
689
+ body: JSON.stringify({ messages: serverMessages }),
690
+ }).catch((err) => console.error("Failed to delete message:", err));
691
+ return filtered;
692
+ });
693
+ },
694
+ [setMessages, question.id], // stable — does NOT depend on `messages`
695
+ );
696
+
697
+ // Persist a refined viz spec back to the server so changes survive refresh
698
+ const handleSpecRefined = useCallback(
699
+ (messageId: string, originalSpec: string, newSpec: string) => {
700
+ setMessages((prev) =>
701
+ prev.map((m) => {
702
+ if (m.id !== messageId) return m;
703
+ const updatedParts = (m.parts ?? []).map((p) => {
704
+ if (p.type !== "text") return p;
705
+ const tp = p as { type: "text"; text: string };
706
+ return {
707
+ ...tp,
708
+ text: tp.text.split(originalSpec).join(newSpec),
709
+ };
710
+ });
711
+ return { ...m, parts: updatedParts };
712
+ }),
713
+ );
714
+ // Persist to server — rebuild server-side Message[] from updated UIMessages
715
+ setMessages((prev) => {
716
+ const serverMessages = prev.map((m) => ({
717
+ id: m.id,
718
+ role: m.role,
719
+ content: (m.parts ?? [])
720
+ .filter(
721
+ (p): p is { type: "text"; text: string } => p.type === "text",
722
+ )
723
+ .map((p) => p.text)
724
+ .join(""),
725
+ parts: m.parts,
726
+ }));
727
+ fetch(`/api/questions/${question.id}`, {
728
+ method: "PATCH",
729
+ headers: { "Content-Type": "application/json" },
730
+ body: JSON.stringify({ messages: serverMessages }),
731
+ }).catch((err) =>
732
+ console.error("Failed to persist refined spec:", err),
733
+ );
734
+ return prev; // no further state change
735
+ });
736
+ },
737
+ [question.id, setMessages],
738
+ );
739
+
740
+ // Build a preference text for annotation dialogs from ALL current group selections
741
+ const annotationPrefs = useMemo(() => {
742
+ const parts: string[] = [];
743
+ for (const [key, sel] of Object.entries(groupSelections)) {
744
+ const prompt = aiSettings.promptGroups[key]?.options[sel];
745
+ if (prompt) parts.push(prompt);
746
+ }
747
+ return parts.join(" ");
748
+ }, [groupSelections, aiSettings.promptGroups]);
749
+
750
+ // Keep the store in sync so FileViewerModal can read the current preference string
751
+ useEffect(() => {
752
+ setLivePreferenceSuffix(annotationPrefs);
753
+ }, [annotationPrefs, setLivePreferenceSuffix]);
754
+
461
755
  // Group annotations by message id so we don't run filter() inside render,
462
756
  // which would produce a new array reference on every ChatView re-render and
463
757
  // defeat React.memo on ChatMessage.
@@ -471,6 +765,22 @@ export default function ChatView({ question }: Props) {
471
765
  }, [annotations]);
472
766
  const EMPTY_ANNOTATIONS: Annotation[] = useMemo(() => [], []);
473
767
 
768
+ // Memoised O(N) bookmark check — avoids traversing messages on every render.
769
+ const hasBookmarkedMessage = useMemo(
770
+ () =>
771
+ readingBookmark
772
+ ? messages.some((m) => m.id === readingBookmark.messageId)
773
+ : false,
774
+ [messages, readingBookmark],
775
+ );
776
+
777
+ // Track whether the user has scrolled away from the bottom.
778
+ const handleScrollArea = useCallback((e: React.UIEvent<HTMLDivElement>) => {
779
+ const el = e.currentTarget;
780
+ stickToBottomRef.current =
781
+ el.scrollHeight - el.scrollTop - el.clientHeight < 80;
782
+ }, []);
783
+
474
784
  useEffect(() => {
475
785
  const el = textareaRef.current;
476
786
  if (!el) return;
@@ -554,127 +864,28 @@ export default function ChatView({ question }: Props) {
554
864
  )}
555
865
  </div>
556
866
 
557
- {/* Sticky bookmark banner — sits outside the scroll container */}
558
- {readingBookmark &&
559
- messages.some((m) => m.id === readingBookmark.messageId) && (
560
- <div className="shrink-0 px-4 pt-2">
561
- <button
562
- onClick={() => {
563
- const el = document.querySelector<HTMLElement>(
564
- '[data-reading-bookmark="true"]',
565
- );
566
- (el ?? bookmarkRef.current)?.scrollIntoView({
567
- behavior: "smooth",
568
- block: "center",
569
- });
570
- }}
571
- 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"
572
- >
573
- <span>↓</span> Resume reading from bookmark
574
- </button>
575
- </div>
576
- )}
577
-
578
- {/* Messages */}
579
- <div
580
- id="chat-scroll-area"
581
- className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
582
- >
583
- {messages.length === 0 && (
584
- <div className="flex items-center justify-center h-full">
585
- <div className="text-center">
586
- <p className="text-sm text-slate-500">No messages yet</p>
587
- <p className="text-xs text-slate-600 mt-1">
588
- Ask about this topic and get explanations with code & diagrams
589
- </p>
590
- </div>
591
- </div>
592
- )}
593
- {messages.map((message) => (
594
- <div
595
- key={message.id}
596
- ref={message.id === readingBookmark?.messageId ? bookmarkRef : null}
597
- >
598
- <ChatMessage
599
- message={message}
600
- annotations={
601
- annotationsByMessageId[message.id] ?? EMPTY_ANNOTATIONS
602
- }
603
- onAnnotationCreate={handleAnnotationCreate}
604
- onAnnotationUpdate={handleAnnotationUpdate}
605
- bookmarkedBlockIndex={
606
- message.id === readingBookmark?.messageId
607
- ? readingBookmark?.blockIndex
608
- : undefined
609
- }
610
- onSetBookmark={handleSetBookmark}
611
- responseLength={groupSelections["length"]}
612
- responseStyle={groupSelections["style"]}
613
- responseAudience={groupSelections["audience"]}
614
- />
615
- </div>
616
- ))}
617
-
618
- {/* Stall warning — shown when streaming has gone silent for 15s */}
619
- {isStalled && status === "streaming" && (
620
- <div className="flex items-center gap-3 bg-amber-500/10 border border-amber-500/20 rounded-lg px-4 py-3 text-sm">
621
- <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
622
- <span className="text-amber-300 flex-1">
623
- Response appears to have stalled.
624
- </span>
625
- <button
626
- onClick={handleContinue}
627
- className="flex items-center gap-1 text-xs text-amber-400 hover:text-amber-200 border border-amber-500/30 hover:border-amber-400/60 rounded-md px-2.5 py-1 transition-colors"
628
- >
629
- Continue <ChevronRight className="w-3 h-3" />
630
- </button>
631
- </div>
632
- )}
633
-
634
- {/* Continue button — only shown when the last response looks truncated */}
635
- {status === "ready" && lastResponseLooksTruncated && (
636
- <div className="flex justify-start pl-10">
637
- <button
638
- onClick={handleContinue}
639
- className="flex items-center gap-1 text-[11px] text-slate-500 hover:text-cyan-400 border border-slate-700/50 hover:border-cyan-500/40 rounded-md px-2.5 py-1 transition-colors"
640
- title="Ask the model to continue from where it left off"
641
- >
642
- <ChevronRight className="w-3 h-3" /> Continue
643
- </button>
644
- </div>
645
- )}
646
- {status === "submitted" && (
647
- <div className="flex items-start gap-3 px-1">
648
- <div className="w-7 h-7 rounded-lg bg-cyan-600/20 flex items-center justify-center shrink-0 mt-0.5">
649
- <Loader2 className="w-3.5 h-3.5 text-cyan-400 animate-spin" />
650
- </div>
651
- <div className="flex items-center gap-1.5 py-2">
652
- <span className="text-sm text-slate-400">Thinking</span>
653
- <span className="flex gap-0.5">
654
- <span
655
- className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
656
- style={{ animationDelay: "0ms" }}
657
- />
658
- <span
659
- className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
660
- style={{ animationDelay: "150ms" }}
661
- />
662
- <span
663
- className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
664
- style={{ animationDelay: "300ms" }}
665
- />
666
- </span>
667
- </div>
668
- </div>
669
- )}
670
- {status === "error" && (
671
- <div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-sm text-red-400">
672
- {error?.message ||
673
- "An error occurred. Check your API key and try again."}
674
- </div>
675
- )}
676
- <div ref={messagesEndRef} />
677
- </div>
867
+ <ChatTranscript
868
+ messages={messages}
869
+ readingBookmark={readingBookmark}
870
+ hasBookmarkedMessage={hasBookmarkedMessage}
871
+ bookmarkRef={bookmarkRef}
872
+ messagesEndRef={messagesEndRef}
873
+ onScroll={handleScrollArea}
874
+ annotationsByMessageId={annotationsByMessageId}
875
+ emptyAnnotations={EMPTY_ANNOTATIONS}
876
+ onAnnotationCreate={handleAnnotationCreate}
877
+ onAnnotationUpdate={handleAnnotationUpdate}
878
+ onSetBookmark={handleSetBookmark}
879
+ preferenceSuffix={annotationPrefs}
880
+ onSpecRefined={handleSpecRefined}
881
+ onDeleteMessage={handleDeleteMessage}
882
+ isLoading={isLoading}
883
+ isStalled={isStalled}
884
+ status={status}
885
+ lastResponseLooksTruncated={lastResponseLooksTruncated}
886
+ onContinue={handleContinue}
887
+ error={error}
888
+ />
678
889
 
679
890
  {/* Input */}
680
891
  <div className="border-t border-slate-800 px-4 py-3 shrink-0">
@@ -684,11 +895,33 @@ export default function ChatView({ question }: Props) {
684
895
  files={question.contextFiles || []}
685
896
  onUpload={(files) => uploadQuestionFiles(question.id, files)}
686
897
  onRemove={(fileId) => removeQuestionFile(question.id, fileId)}
898
+ onLink={(fileId, originalName) =>
899
+ linkFileToQuestion(question.id, fileId, originalName)
900
+ }
901
+ downloadBase={`/api/questions/${question.id}/context-files`}
687
902
  label="question"
688
903
  compact
689
904
  />
690
905
  </div>
691
906
 
907
+ {/* Link other conversations as context */}
908
+ <div className="flex items-center gap-2 mb-2">
909
+ <button
910
+ type="button"
911
+ onClick={() => setShowLinkedPicker(true)}
912
+ className="inline-flex items-center gap-1 text-[10px] text-slate-600 hover:text-violet-400 transition-colors"
913
+ title="Link conversations from this topic as context"
914
+ >
915
+ <Link className="w-2.5 h-2.5" />
916
+ Link conversations
917
+ </button>
918
+ {(question.linkedConversationIds?.length ?? 0) > 0 && (
919
+ <span className="text-[10px] bg-violet-500/15 text-violet-400 px-1.5 py-0.5 rounded">
920
+ {question.linkedConversationIds!.length} linked
921
+ </span>
922
+ )}
923
+ </div>
924
+
692
925
  <form onSubmit={handleSubmit}>
693
926
  {/* Attached image thumbnails */}
694
927
  {attachedImages.length > 0 && (
@@ -839,6 +1072,15 @@ export default function ChatView({ question }: Props) {
839
1072
  />
840
1073
  )}
841
1074
  </div>
1075
+
1076
+ {/* Linked conversations picker modal */}
1077
+ {showLinkedPicker && (
1078
+ <LinkedConvosPicker
1079
+ question={question}
1080
+ topicId={question.topicId}
1081
+ onClose={() => setShowLinkedPicker(false)}
1082
+ />
1083
+ )}
842
1084
  </div>
843
1085
  );
844
1086
  }