create-interview-cockpit 0.2.0 → 0.4.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/README.md +23 -0
- package/package.json +1 -1
- package/template/client/package-lock.json +23 -0
- package/template/client/package.json +2 -0
- package/template/client/src/App.tsx +48 -12
- package/template/client/src/api.ts +39 -0
- package/template/client/src/components/AiSettingsModal.tsx +827 -0
- package/template/client/src/components/ChatView.tsx +173 -136
- package/template/client/src/components/MarkdownRenderer.tsx +5 -0
- package/template/client/src/components/Sidebar.tsx +3 -1
- package/template/client/src/components/VizCraftEmbed.tsx +502 -0
- package/template/client/src/store.ts +76 -0
- package/template/client/src/types.ts +1 -0
- package/template/cockpit.json +1 -1
- package/template/data/ai-settings.json +49 -0
- package/template/package.json +1 -1
- package/template/server/src/index.ts +84 -34
- package/template/server/src/storage.ts +96 -0
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { useChat } from "@ai-sdk/react";
|
|
2
2
|
import { DefaultChatTransport } from "ai";
|
|
3
|
-
import type { FileUIPart } from "ai";
|
|
4
|
-
import {
|
|
3
|
+
import type { FileUIPart, UIMessage } from "ai";
|
|
4
|
+
import {
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useMemo,
|
|
9
|
+
useCallback,
|
|
10
|
+
Fragment,
|
|
11
|
+
} from "react";
|
|
5
12
|
import type { Question, Annotation, ReadingBookmark } from "../types";
|
|
6
13
|
import { useStore } from "../store";
|
|
7
14
|
import ChatMessage from "./ChatMessage";
|
|
@@ -13,44 +20,15 @@ import {
|
|
|
13
20
|
RotateCcw,
|
|
14
21
|
ImagePlus,
|
|
15
22
|
X,
|
|
23
|
+
AlertTriangle,
|
|
24
|
+
ChevronRight,
|
|
16
25
|
} from "lucide-react";
|
|
17
26
|
|
|
18
27
|
interface Props {
|
|
19
28
|
question: Question;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
|
-
type
|
|
23
|
-
type ResponseStyle = "prose" | "bullets" | "structured";
|
|
24
|
-
type ResponseAudience = "normal" | "beginner";
|
|
25
|
-
|
|
26
|
-
interface ResponsePreferenceCache {
|
|
27
|
-
length?: ResponseLength;
|
|
28
|
-
style?: ResponseStyle;
|
|
29
|
-
audience?: ResponseAudience;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const responseLengthPrompts: Record<ResponseLength, string> = {
|
|
33
|
-
concise:
|
|
34
|
-
"Keep the response concise. Aim for roughly 300 characters of text when possible. These limits do not apply to mermaid diagrams. You can generate as many as you want to explain the solution effectively. Prioritize diagrams over text.",
|
|
35
|
-
moderate:
|
|
36
|
-
"Keep the response moderately detailed. Aim for roughly 550 characters of text when possible.",
|
|
37
|
-
normal:
|
|
38
|
-
"Use a fuller answer with enough context to explain the idea clearly.",
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const responseStylePrompts: Record<ResponseStyle, string> = {
|
|
42
|
-
prose:
|
|
43
|
-
"Use natural prose with short paragraphs. Avoid bullet lists and numbered lists unless I explicitly ask for them.",
|
|
44
|
-
bullets: "Use bullet points and short lists as the main format.",
|
|
45
|
-
structured:
|
|
46
|
-
"Use structured sections with headings and numbered steps when helpful.",
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const responseAudiencePrompts: Record<ResponseAudience, string> = {
|
|
50
|
-
normal: "",
|
|
51
|
-
beginner:
|
|
52
|
-
"When using technical terms or abbreviations, immediately expand their meaning in square brackets right after the term — e.g. 'TCP [Transmission Control Protocol — a connection-oriented transport protocol]' or 'idempotent [an operation that produces the same result no matter how many times it is applied]'. Do this throughout your response so I never need to look anything up.",
|
|
53
|
-
};
|
|
31
|
+
type ResponsePreferenceCache = Record<string, string | undefined>;
|
|
54
32
|
|
|
55
33
|
function findLastUserMessageIndex(messages: any[]): number {
|
|
56
34
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
@@ -64,23 +42,16 @@ function findLastUserMessageIndex(messages: any[]): number {
|
|
|
64
42
|
|
|
65
43
|
function buildPreferenceSuffix(
|
|
66
44
|
cache: ResponsePreferenceCache,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
responseAudience: ResponseAudience,
|
|
45
|
+
selections: Record<string, string>,
|
|
46
|
+
promptGroups: Record<string, { options: Record<string, string> }>,
|
|
70
47
|
): string {
|
|
71
48
|
const updates: string[] = [];
|
|
72
49
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
updates.push(responseStylePrompts[responseStyle]);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (cache.audience !== responseAudience) {
|
|
82
|
-
const audiencePrompt = responseAudiencePrompts[responseAudience];
|
|
83
|
-
if (audiencePrompt) updates.push(audiencePrompt);
|
|
50
|
+
for (const [key, sel] of Object.entries(selections)) {
|
|
51
|
+
if (cache[key] !== sel) {
|
|
52
|
+
const prompt = promptGroups[key]?.options[sel];
|
|
53
|
+
if (prompt) updates.push(prompt);
|
|
54
|
+
}
|
|
84
55
|
}
|
|
85
56
|
|
|
86
57
|
if (updates.length === 0) {
|
|
@@ -157,17 +128,20 @@ export default function ChatView({ question }: Props) {
|
|
|
157
128
|
topics,
|
|
158
129
|
selectedTopicId,
|
|
159
130
|
codeSnippets,
|
|
131
|
+
aiSettings,
|
|
160
132
|
} = useStore();
|
|
161
133
|
const [showContext, setShowContext] = useState(false);
|
|
162
134
|
const [systemContext, setSystemContext] = useState(
|
|
163
135
|
question.systemContext || "",
|
|
164
136
|
);
|
|
165
137
|
const [input, setInput] = useState("");
|
|
166
|
-
const [
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
138
|
+
const [groupSelections, setGroupSelections] = useState<
|
|
139
|
+
Record<string, string>
|
|
140
|
+
>(() =>
|
|
141
|
+
Object.fromEntries(
|
|
142
|
+
Object.entries(aiSettings.promptGroups).map(([k, g]) => [k, g.default]),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
171
145
|
const [alwaysSendPrefs, setAlwaysSendPrefs] = useState(false);
|
|
172
146
|
const [annotations, setAnnotations] = useState<Annotation[]>(
|
|
173
147
|
question.annotations ?? [],
|
|
@@ -183,6 +157,20 @@ export default function ChatView({ question }: Props) {
|
|
|
183
157
|
const responsePreferenceCacheRef = useRef<ResponsePreferenceCache>({});
|
|
184
158
|
const pendingResponsePreferenceCacheRef =
|
|
185
159
|
useRef<ResponsePreferenceCache | null>(null);
|
|
160
|
+
const aiSettingsRef = useRef(aiSettings);
|
|
161
|
+
const lastStreamActivityRef = useRef<number>(Date.now());
|
|
162
|
+
const [isStalled, setIsStalled] = useState(false);
|
|
163
|
+
|
|
164
|
+
// Sync groupSelections when new groups are added via settings
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
setGroupSelections((prev) => {
|
|
167
|
+
const additions: Record<string, string> = {};
|
|
168
|
+
for (const [k, g] of Object.entries(aiSettings.promptGroups)) {
|
|
169
|
+
if (!(k in prev)) additions[k] = g.default;
|
|
170
|
+
}
|
|
171
|
+
return Object.keys(additions).length ? { ...prev, ...additions } : prev;
|
|
172
|
+
});
|
|
173
|
+
}, [aiSettings.promptGroups]);
|
|
186
174
|
|
|
187
175
|
// ── Inline image attachments (per-message, ephemeral) ──────────────────────
|
|
188
176
|
const [attachedImages, setAttachedImages] = useState<
|
|
@@ -232,9 +220,8 @@ export default function ChatView({ question }: Props) {
|
|
|
232
220
|
questionTitle: question.title,
|
|
233
221
|
codeContextFiles: question.codeContextFiles,
|
|
234
222
|
systemContext,
|
|
235
|
-
responseLength,
|
|
236
|
-
|
|
237
|
-
responseAudience,
|
|
223
|
+
responseLength: "normal" as string,
|
|
224
|
+
groupSelections,
|
|
238
225
|
alwaysSendPrefs,
|
|
239
226
|
codeSnippets,
|
|
240
227
|
});
|
|
@@ -250,12 +237,12 @@ export default function ChatView({ question }: Props) {
|
|
|
250
237
|
questionTitle: question.title,
|
|
251
238
|
codeContextFiles: question.codeContextFiles,
|
|
252
239
|
systemContext,
|
|
253
|
-
responseLength,
|
|
254
|
-
|
|
255
|
-
responseAudience,
|
|
240
|
+
responseLength: groupSelections["length"] ?? "normal",
|
|
241
|
+
groupSelections,
|
|
256
242
|
alwaysSendPrefs,
|
|
257
243
|
codeSnippets,
|
|
258
244
|
};
|
|
245
|
+
aiSettingsRef.current = aiSettings;
|
|
259
246
|
|
|
260
247
|
const transport = useMemo(
|
|
261
248
|
() =>
|
|
@@ -268,9 +255,8 @@ export default function ChatView({ question }: Props) {
|
|
|
268
255
|
requestOptionsRef.current.alwaysSendPrefs
|
|
269
256
|
? {}
|
|
270
257
|
: responsePreferenceCacheRef.current,
|
|
271
|
-
requestOptionsRef.current.
|
|
272
|
-
|
|
273
|
-
requestOptionsRef.current.responseAudience,
|
|
258
|
+
requestOptionsRef.current.groupSelections,
|
|
259
|
+
aiSettingsRef.current.promptGroups,
|
|
274
260
|
)
|
|
275
261
|
: "";
|
|
276
262
|
|
|
@@ -280,11 +266,7 @@ export default function ChatView({ question }: Props) {
|
|
|
280
266
|
);
|
|
281
267
|
|
|
282
268
|
pendingResponsePreferenceCacheRef.current = preferenceSuffix
|
|
283
|
-
? {
|
|
284
|
-
length: requestOptionsRef.current.responseLength,
|
|
285
|
-
style: requestOptionsRef.current.responseStyle,
|
|
286
|
-
audience: requestOptionsRef.current.responseAudience,
|
|
287
|
-
}
|
|
269
|
+
? { ...requestOptionsRef.current.groupSelections }
|
|
288
270
|
: null;
|
|
289
271
|
|
|
290
272
|
return {
|
|
@@ -304,10 +286,12 @@ export default function ChatView({ question }: Props) {
|
|
|
304
286
|
// and triggering an update loop.
|
|
305
287
|
const initialMessages = useMemo(
|
|
306
288
|
() =>
|
|
307
|
-
question.messages.map((m) => ({
|
|
289
|
+
(question.messages ?? []).map((m) => ({
|
|
308
290
|
id: m.id,
|
|
309
291
|
role: m.role as "user" | "assistant",
|
|
310
|
-
parts:
|
|
292
|
+
parts: (m.parts ?? [
|
|
293
|
+
{ type: "text" as const, text: m.content },
|
|
294
|
+
]) as UIMessage["parts"],
|
|
311
295
|
})),
|
|
312
296
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
313
297
|
[question.id],
|
|
@@ -337,6 +321,60 @@ export default function ChatView({ question }: Props) {
|
|
|
337
321
|
|
|
338
322
|
const isLoading = status === "streaming" || status === "submitted";
|
|
339
323
|
|
|
324
|
+
// ── Stall detection ────────────────────────────────────────────────────────
|
|
325
|
+
// Track when streaming content last arrived
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (status === "streaming") {
|
|
328
|
+
lastStreamActivityRef.current = Date.now();
|
|
329
|
+
}
|
|
330
|
+
}, [messages, status]);
|
|
331
|
+
|
|
332
|
+
// Start an interval when streaming begins; fire stall if silent for 15s
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (status !== "streaming") {
|
|
335
|
+
setIsStalled(false);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
lastStreamActivityRef.current = Date.now();
|
|
339
|
+
const id = window.setInterval(() => {
|
|
340
|
+
if (Date.now() - lastStreamActivityRef.current > 15_000) {
|
|
341
|
+
setIsStalled(true);
|
|
342
|
+
}
|
|
343
|
+
}, 3_000);
|
|
344
|
+
return () => window.clearInterval(id);
|
|
345
|
+
}, [status]);
|
|
346
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
const handleContinue = useCallback(() => {
|
|
349
|
+
setIsStalled(false);
|
|
350
|
+
sendMessage({ text: "Please continue from where you left off." });
|
|
351
|
+
}, [sendMessage]);
|
|
352
|
+
|
|
353
|
+
// Only show the Continue button if the last assistant message looks truncated
|
|
354
|
+
// (doesn't end with sentence-terminating punctuation or a closing code fence).
|
|
355
|
+
const lastResponseLooksTruncated = useMemo(() => {
|
|
356
|
+
if (messages.length === 0) return false;
|
|
357
|
+
const last = messages[messages.length - 1];
|
|
358
|
+
if (last.role !== "assistant") return false;
|
|
359
|
+
// Find the last text part
|
|
360
|
+
const parts: any[] = (last as any).parts ?? [];
|
|
361
|
+
let text = "";
|
|
362
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
363
|
+
const p = parts[i];
|
|
364
|
+
if (p?.type === "text" && p.text) {
|
|
365
|
+
text = p.text;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const trimmed = text.trimEnd();
|
|
370
|
+
if (!trimmed) return false;
|
|
371
|
+
// Walk back to last non-empty line
|
|
372
|
+
const lines = trimmed.split("\n");
|
|
373
|
+
const lastLine = (lines.filter(Boolean).pop() ?? "").trimEnd();
|
|
374
|
+
// Complete if ends with sentence punctuation or structural close chars
|
|
375
|
+
return !/[.!?`\])>]$/.test(lastLine);
|
|
376
|
+
}, [messages]);
|
|
377
|
+
|
|
340
378
|
useEffect(() => {
|
|
341
379
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
342
380
|
}, [messages]);
|
|
@@ -570,12 +608,41 @@ export default function ChatView({ question }: Props) {
|
|
|
570
608
|
: undefined
|
|
571
609
|
}
|
|
572
610
|
onSetBookmark={handleSetBookmark}
|
|
573
|
-
responseLength={
|
|
574
|
-
responseStyle={
|
|
575
|
-
responseAudience={
|
|
611
|
+
responseLength={groupSelections["length"]}
|
|
612
|
+
responseStyle={groupSelections["style"]}
|
|
613
|
+
responseAudience={groupSelections["audience"]}
|
|
576
614
|
/>
|
|
577
615
|
</div>
|
|
578
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
|
+
)}
|
|
579
646
|
{status === "submitted" && (
|
|
580
647
|
<div className="flex items-start gap-3 px-1">
|
|
581
648
|
<div className="w-7 h-7 rounded-lg bg-cyan-600/20 flex items-center justify-center shrink-0 mt-0.5">
|
|
@@ -705,70 +772,40 @@ export default function ChatView({ question }: Props) {
|
|
|
705
772
|
</form>
|
|
706
773
|
|
|
707
774
|
{/* Response controls */}
|
|
708
|
-
<div className="flex items-center gap-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
|
|
743
|
-
responseStyle === key
|
|
744
|
-
? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
|
|
745
|
-
: "text-slate-500 hover:text-slate-300 border border-transparent"
|
|
746
|
-
}`}
|
|
747
|
-
>
|
|
748
|
-
{label}
|
|
749
|
-
</button>
|
|
750
|
-
))}
|
|
751
|
-
</div>
|
|
752
|
-
<div className="w-px h-4 bg-slate-700" />
|
|
753
|
-
<div className="flex items-center gap-1.5">
|
|
754
|
-
<span className="text-[10px] text-slate-500 uppercase tracking-wider">
|
|
755
|
-
Mode
|
|
756
|
-
</span>
|
|
757
|
-
{(["normal", "beginner"] as const).map((opt) => (
|
|
758
|
-
<button
|
|
759
|
-
key={opt}
|
|
760
|
-
onClick={() => setResponseAudience(opt)}
|
|
761
|
-
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
|
|
762
|
-
responseAudience === opt
|
|
763
|
-
? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
|
|
764
|
-
: "text-slate-500 hover:text-slate-300 border border-transparent"
|
|
765
|
-
}`}
|
|
766
|
-
>
|
|
767
|
-
{opt.charAt(0).toUpperCase() + opt.slice(1)}
|
|
768
|
-
</button>
|
|
769
|
-
))}
|
|
770
|
-
</div>
|
|
771
|
-
<div className="w-px h-4 bg-slate-700" />
|
|
775
|
+
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
|
776
|
+
{Object.entries(aiSettings.promptGroups).map(
|
|
777
|
+
([groupKey, group], idx) => (
|
|
778
|
+
<Fragment key={groupKey}>
|
|
779
|
+
{idx > 0 && <div className="w-px h-4 bg-slate-700 shrink-0" />}
|
|
780
|
+
<div className="flex items-center gap-1">
|
|
781
|
+
<span className="text-[10px] text-slate-500 uppercase tracking-wider shrink-0">
|
|
782
|
+
{group.label.replace(/^Response\s+/i, "")}
|
|
783
|
+
</span>
|
|
784
|
+
{Object.keys(group.options).map((optKey) => (
|
|
785
|
+
<button
|
|
786
|
+
key={optKey}
|
|
787
|
+
onClick={() =>
|
|
788
|
+
setGroupSelections((p) => ({
|
|
789
|
+
...p,
|
|
790
|
+
[groupKey]: optKey,
|
|
791
|
+
}))
|
|
792
|
+
}
|
|
793
|
+
className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
|
|
794
|
+
groupSelections[groupKey] === optKey
|
|
795
|
+
? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
|
|
796
|
+
: "text-slate-500 hover:text-slate-300 border border-transparent"
|
|
797
|
+
}`}
|
|
798
|
+
>
|
|
799
|
+
{optKey.charAt(0).toUpperCase() + optKey.slice(1)}
|
|
800
|
+
</button>
|
|
801
|
+
))}
|
|
802
|
+
</div>
|
|
803
|
+
</Fragment>
|
|
804
|
+
),
|
|
805
|
+
)}
|
|
806
|
+
{Object.keys(aiSettings.promptGroups).length > 0 && (
|
|
807
|
+
<div className="w-px h-4 bg-slate-700 shrink-0" />
|
|
808
|
+
)}
|
|
772
809
|
<button
|
|
773
810
|
onClick={() => setAlwaysSendPrefs((p) => !p)}
|
|
774
811
|
title={
|
|
@@ -4,6 +4,7 @@ import { useMemo, useRef, useEffect } from "react";
|
|
|
4
4
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
5
5
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
|
6
6
|
import MermaidDiagram from "./MermaidDiagram";
|
|
7
|
+
import VizCraftEmbed from "./VizCraftEmbed";
|
|
7
8
|
import { useStore } from "../store";
|
|
8
9
|
import { Bookmark } from "lucide-react";
|
|
9
10
|
|
|
@@ -34,6 +35,10 @@ const markdownComponents: Components = {
|
|
|
34
35
|
return <MermaidDiagram chart={codeString} />;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
if (lang === "viz") {
|
|
39
|
+
return <VizCraftEmbed spec={codeString} />;
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
return (
|
|
38
43
|
<SyntaxHighlighter
|
|
39
44
|
style={oneDark}
|
|
@@ -237,7 +237,9 @@ export default function Sidebar() {
|
|
|
237
237
|
<button
|
|
238
238
|
onClick={(e) => {
|
|
239
239
|
e.stopPropagation();
|
|
240
|
-
|
|
240
|
+
if (window.confirm(`Delete "${q.title}"? This cannot be undone.`)) {
|
|
241
|
+
removeQuestion(q.id, topicId);
|
|
242
|
+
}
|
|
241
243
|
}}
|
|
242
244
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
|
|
243
245
|
>
|