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.
- package/package.json +1 -1
- package/template/client/package-lock.json +753 -1
- package/template/client/package.json +4 -0
- package/template/client/src/App.tsx +20 -0
- package/template/client/src/api.ts +455 -3
- package/template/client/src/components/AiSettingsModal.tsx +855 -248
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +132 -27
- package/template/client/src/components/ChatView.tsx +365 -123
- package/template/client/src/components/CodeContextPanel.tsx +714 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
- package/template/client/src/components/DocRefModal.tsx +551 -0
- package/template/client/src/components/FileAttachments.tsx +128 -12
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +219 -2
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +397 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +412 -25
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +477 -0
- package/template/client/src/store.ts +416 -2
- package/template/client/src/types.ts +41 -1
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +144 -2
- package/template/server/src/index.ts +1890 -188
- package/template/server/src/infra-runner.ts +1104 -0
- 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(
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
}
|