create-interview-cockpit 0.5.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 +734 -1
- package/template/client/package.json +1 -0
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +321 -4
- package/template/client/src/components/AiSettingsModal.tsx +818 -425
- package/template/client/src/components/ChatMessage.tsx +34 -12
- package/template/client/src/components/ChatView.tsx +298 -121
- package/template/client/src/components/CodeContextPanel.tsx +419 -2
- package/template/client/src/components/CodeRunnerModal.tsx +1601 -120
- package/template/client/src/components/DocRefModal.tsx +55 -6
- package/template/client/src/components/FileAttachments.tsx +20 -4
- 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 +22 -8
- 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 +184 -0
- package/template/client/src/components/VizCraftEmbed.tsx +257 -13
- 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 +219 -6
- package/template/client/src/types.ts +35 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/server/src/google-drive.ts +37 -3
- package/template/server/src/index.ts +693 -52
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +13 -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"
|
|
45
|
-
p.type === "reasoning" && typeof (p as any).
|
|
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.
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
}
|