create-interview-cockpit 0.5.0 → 0.7.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 (30) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +384 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +530 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1895 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +960 -0
  23. package/template/client/src/store.ts +250 -6
  24. package/template/client/src/types.ts +36 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/cockpit.json +1 -1
  27. package/template/server/src/google-drive.ts +39 -3
  28. package/template/server/src/index.ts +954 -52
  29. package/template/server/src/infra-runner.ts +1104 -0
  30. package/template/server/src/storage.ts +22 -3
@@ -8,6 +8,7 @@ import {
8
8
  ChevronDown,
9
9
  ChevronRight,
10
10
  Brain,
11
+ Trash2,
11
12
  } from "lucide-react";
12
13
  import TextAnnotator from "./TextAnnotator";
13
14
  import type { Annotation } from "../types";
@@ -25,6 +26,7 @@ interface Props {
25
26
  originalSpec: string,
26
27
  newSpec: string,
27
28
  ) => void;
29
+ onDeleteMessage?: (messageId: string) => void;
28
30
  }
29
31
 
30
32
  function getTextContent(message: UIMessage): string {
@@ -41,10 +43,10 @@ function getReasoningContent(message: UIMessage): string {
41
43
  if (!message.parts) return "";
42
44
  return message.parts
43
45
  .filter(
44
- (p): p is { type: "reasoning"; reasoning: string } =>
45
- p.type === "reasoning" && typeof (p as any).reasoning === "string",
46
+ (p): p is Extract<UIMessage["parts"][number], { type: "reasoning" }> =>
47
+ p.type === "reasoning" && typeof (p as any).text === "string",
46
48
  )
47
- .map((p) => p.reasoning)
49
+ .map((p) => p.text)
48
50
  .join("\n\n");
49
51
  }
50
52
 
@@ -87,12 +89,26 @@ const ChatMessage = memo(function ChatMessage({
87
89
  onSetBookmark,
88
90
  preferenceSuffix,
89
91
  onSpecRefined,
92
+ onDeleteMessage,
90
93
  }: Props) {
91
94
  const isUser = message.role === "user";
92
95
  const content = getTextContent(message);
93
96
  const reasoning = !isUser ? getReasoningContent(message) : "";
94
97
  const [copied, setCopied] = useState(false);
95
98
 
99
+ // Stable wrappers so MarkdownRenderer's `components` useMemo doesn't invalidate
100
+ // on every ChatMessage re-render (which would remount InlineCodeBlock and cause
101
+ // a Zustand update loop).
102
+ const stableBookmarkBlock = useCallback(
103
+ (idx: number) => onSetBookmark?.(message.id, idx),
104
+ [onSetBookmark, message.id],
105
+ );
106
+ const stableSpecRefined = useCallback(
107
+ (orig: string, refined: string) =>
108
+ onSpecRefined?.(message.id, orig, refined),
109
+ [onSpecRefined, message.id],
110
+ );
111
+
96
112
  const handleCopy = useCallback(() => {
97
113
  if (!content) return;
98
114
  navigator.clipboard.writeText(content).then(() => {
@@ -101,6 +117,10 @@ const ChatMessage = memo(function ChatMessage({
101
117
  });
102
118
  }, [content]);
103
119
 
120
+ const handleDelete = useCallback(() => {
121
+ onDeleteMessage?.(message.id);
122
+ }, [onDeleteMessage, message.id]);
123
+
104
124
  return (
105
125
  <div className="group flex gap-3 animate-fadeIn">
106
126
  <div
@@ -134,6 +154,15 @@ const ChatMessage = memo(function ChatMessage({
134
154
  )}
135
155
  </button>
136
156
  )}
157
+ {onDeleteMessage && (
158
+ <button
159
+ onClick={handleDelete}
160
+ className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded text-slate-500 hover:text-red-400 hover:bg-slate-700"
161
+ title="Delete message"
162
+ >
163
+ <Trash2 className="w-3 h-3" />
164
+ </button>
165
+ )}
137
166
  </div>
138
167
  <div className="text-sm leading-relaxed text-slate-200">
139
168
  {isUser ? (
@@ -175,17 +204,10 @@ const ChatMessage = memo(function ChatMessage({
175
204
  onAnnotationUpdate={onAnnotationUpdate ?? (() => {})}
176
205
  bookmarkedBlockIndex={bookmarkedBlockIndex}
177
206
  onBookmarkBlock={
178
- onSetBookmark
179
- ? (idx) => onSetBookmark(message.id, idx)
180
- : undefined
207
+ onSetBookmark ? stableBookmarkBlock : undefined
181
208
  }
182
209
  preferenceSuffix={preferenceSuffix}
183
- onSpecRefined={
184
- onSpecRefined
185
- ? (orig, refined) =>
186
- onSpecRefined(message.id, orig, refined)
187
- : undefined
188
- }
210
+ onSpecRefined={onSpecRefined ? stableSpecRefined : undefined}
189
211
  />
190
212
  </>
191
213
  )}
@@ -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,6 +121,178 @@ 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,
@@ -131,8 +306,11 @@ export default function ChatView({ question }: Props) {
131
306
  codeSnippets,
132
307
  aiSettings,
133
308
  setLivePreferenceSuffix,
309
+ linkConversation,
310
+ unlinkConversation,
134
311
  } = useStore();
135
312
  const [showContext, setShowContext] = useState(false);
313
+ const [showLinkedPicker, setShowLinkedPicker] = useState(false);
136
314
  const [systemContext, setSystemContext] = useState(
137
315
  question.systemContext || "",
138
316
  );
@@ -155,6 +333,11 @@ export default function ChatView({ question }: Props) {
155
333
  >(question.readingBookmark);
156
334
  const bookmarkRef = useRef<HTMLDivElement | null>(null);
157
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);
158
341
  const textareaRef = useRef<HTMLTextAreaElement>(null);
159
342
  const imageInputRef = useRef<HTMLInputElement>(null);
160
343
  const systemContextSaveTimeoutRef = useRef<number | null>(null);
@@ -228,6 +411,7 @@ export default function ChatView({ question }: Props) {
228
411
  groupSelections,
229
412
  alwaysSendPrefs,
230
413
  codeSnippets,
414
+ linkedConversationIds: question.linkedConversationIds ?? [],
231
415
  });
232
416
 
233
417
  const currentTopic = topics.find((t) => t.id === selectedTopicId);
@@ -245,6 +429,7 @@ export default function ChatView({ question }: Props) {
245
429
  groupSelections,
246
430
  alwaysSendPrefs,
247
431
  codeSnippets,
432
+ linkedConversationIds: question.linkedConversationIds ?? [],
248
433
  };
249
434
  aiSettingsRef.current = aiSettings;
250
435
 
@@ -379,9 +564,26 @@ export default function ChatView({ question }: Props) {
379
564
  return !/[.!?`\])>]$/.test(lastLine);
380
565
  }, [messages]);
381
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.
382
570
  useEffect(() => {
571
+ stickToBottomRef.current = true;
383
572
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
384
- }, [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
385
587
 
386
588
  useEffect(() => {
387
589
  if (systemContext === question.systemContext) return;
@@ -462,6 +664,36 @@ export default function ChatView({ question }: Props) {
462
664
  [question.id],
463
665
  );
464
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
+
465
697
  // Persist a refined viz spec back to the server so changes survive refresh
466
698
  const handleSpecRefined = useCallback(
467
699
  (messageId: string, originalSpec: string, newSpec: string) => {
@@ -533,6 +765,22 @@ export default function ChatView({ question }: Props) {
533
765
  }, [annotations]);
534
766
  const EMPTY_ANNOTATIONS: Annotation[] = useMemo(() => [], []);
535
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
+
536
784
  useEffect(() => {
537
785
  const el = textareaRef.current;
538
786
  if (!el) return;
@@ -616,126 +864,28 @@ export default function ChatView({ question }: Props) {
616
864
  )}
617
865
  </div>
618
866
 
619
- {/* Sticky bookmark banner — sits outside the scroll container */}
620
- {readingBookmark &&
621
- messages.some((m) => m.id === readingBookmark.messageId) && (
622
- <div className="shrink-0 px-4 pt-2">
623
- <button
624
- onClick={() => {
625
- const el = document.querySelector<HTMLElement>(
626
- '[data-reading-bookmark="true"]',
627
- );
628
- (el ?? bookmarkRef.current)?.scrollIntoView({
629
- behavior: "smooth",
630
- block: "center",
631
- });
632
- }}
633
- 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"
634
- >
635
- <span>↓</span> Resume reading from bookmark
636
- </button>
637
- </div>
638
- )}
639
-
640
- {/* Messages */}
641
- <div
642
- id="chat-scroll-area"
643
- className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
644
- >
645
- {messages.length === 0 && (
646
- <div className="flex items-center justify-center h-full">
647
- <div className="text-center">
648
- <p className="text-sm text-slate-500">No messages yet</p>
649
- <p className="text-xs text-slate-600 mt-1">
650
- Ask about this topic and get explanations with code & diagrams
651
- </p>
652
- </div>
653
- </div>
654
- )}
655
- {messages.map((message) => (
656
- <div
657
- key={message.id}
658
- ref={message.id === readingBookmark?.messageId ? bookmarkRef : null}
659
- >
660
- <ChatMessage
661
- message={message}
662
- annotations={
663
- annotationsByMessageId[message.id] ?? EMPTY_ANNOTATIONS
664
- }
665
- onAnnotationCreate={handleAnnotationCreate}
666
- onAnnotationUpdate={handleAnnotationUpdate}
667
- bookmarkedBlockIndex={
668
- message.id === readingBookmark?.messageId
669
- ? readingBookmark?.blockIndex
670
- : undefined
671
- }
672
- onSetBookmark={handleSetBookmark}
673
- preferenceSuffix={annotationPrefs}
674
- onSpecRefined={handleSpecRefined}
675
- />
676
- </div>
677
- ))}
678
-
679
- {/* Stall warning — shown when streaming has gone silent for 15s */}
680
- {isStalled && status === "streaming" && (
681
- <div className="flex items-center gap-3 bg-amber-500/10 border border-amber-500/20 rounded-lg px-4 py-3 text-sm">
682
- <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
683
- <span className="text-amber-300 flex-1">
684
- Response appears to have stalled.
685
- </span>
686
- <button
687
- onClick={handleContinue}
688
- 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"
689
- >
690
- Continue <ChevronRight className="w-3 h-3" />
691
- </button>
692
- </div>
693
- )}
694
-
695
- {/* Continue button — only shown when the last response looks truncated */}
696
- {status === "ready" && lastResponseLooksTruncated && (
697
- <div className="flex justify-start pl-10">
698
- <button
699
- onClick={handleContinue}
700
- 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"
701
- title="Ask the model to continue from where it left off"
702
- >
703
- <ChevronRight className="w-3 h-3" /> Continue
704
- </button>
705
- </div>
706
- )}
707
- {status === "submitted" && (
708
- <div className="flex items-start gap-3 px-1">
709
- <div className="w-7 h-7 rounded-lg bg-cyan-600/20 flex items-center justify-center shrink-0 mt-0.5">
710
- <Loader2 className="w-3.5 h-3.5 text-cyan-400 animate-spin" />
711
- </div>
712
- <div className="flex items-center gap-1.5 py-2">
713
- <span className="text-sm text-slate-400">Thinking</span>
714
- <span className="flex gap-0.5">
715
- <span
716
- className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
717
- style={{ animationDelay: "0ms" }}
718
- />
719
- <span
720
- className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
721
- style={{ animationDelay: "150ms" }}
722
- />
723
- <span
724
- className="w-1 h-1 rounded-full bg-slate-500 animate-bounce"
725
- style={{ animationDelay: "300ms" }}
726
- />
727
- </span>
728
- </div>
729
- </div>
730
- )}
731
- {status === "error" && (
732
- <div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-sm text-red-400">
733
- {error?.message ||
734
- "An error occurred. Check your API key and try again."}
735
- </div>
736
- )}
737
- <div ref={messagesEndRef} />
738
- </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
+ />
739
889
 
740
890
  {/* Input */}
741
891
  <div className="border-t border-slate-800 px-4 py-3 shrink-0">
@@ -754,6 +904,24 @@ export default function ChatView({ question }: Props) {
754
904
  />
755
905
  </div>
756
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
+
757
925
  <form onSubmit={handleSubmit}>
758
926
  {/* Attached image thumbnails */}
759
927
  {attachedImages.length > 0 && (
@@ -904,6 +1072,15 @@ export default function ChatView({ question }: Props) {
904
1072
  />
905
1073
  )}
906
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
+ )}
907
1084
  </div>
908
1085
  );
909
1086
  }