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.
@@ -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 { useEffect, useRef, useState, useMemo, useCallback } from "react";
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 ResponseLength = "normal" | "moderate" | "concise";
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
- responseLength: ResponseLength,
68
- responseStyle: ResponseStyle,
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
- if (cache.length !== responseLength) {
74
- updates.push(responseLengthPrompts[responseLength]);
75
- }
76
-
77
- if (cache.style !== responseStyle) {
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 [responseLength, setResponseLength] =
167
- useState<ResponseLength>("normal");
168
- const [responseStyle, setResponseStyle] = useState<ResponseStyle>("prose");
169
- const [responseAudience, setResponseAudience] =
170
- useState<ResponseAudience>("normal");
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
- responseStyle,
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
- responseStyle,
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.responseLength,
272
- requestOptionsRef.current.responseStyle,
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: [{ type: "text" as const, text: m.content }],
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={responseLength}
574
- responseStyle={responseStyle}
575
- responseAudience={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-4 mt-2">
709
- <div className="flex items-center gap-1.5">
710
- <span className="text-[10px] text-slate-500 uppercase tracking-wider">
711
- Length
712
- </span>
713
- {(["normal", "moderate", "concise"] as const).map((opt) => (
714
- <button
715
- key={opt}
716
- onClick={() => setResponseLength(opt)}
717
- className={`px-2 py-0.5 text-[11px] rounded-md transition-colors ${
718
- responseLength === opt
719
- ? "bg-cyan-600/30 text-cyan-300 border border-cyan-600/50"
720
- : "text-slate-500 hover:text-slate-300 border border-transparent"
721
- }`}
722
- >
723
- {opt.charAt(0).toUpperCase() + opt.slice(1)}
724
- </button>
725
- ))}
726
- </div>
727
- <div className="w-px h-4 bg-slate-700" />
728
- <div className="flex items-center gap-1.5">
729
- <span className="text-[10px] text-slate-500 uppercase tracking-wider">
730
- Style
731
- </span>
732
- {(
733
- [
734
- ["prose", "Prose"],
735
- ["bullets", "Bullets"],
736
- ["structured", "Structured"],
737
- ] as const
738
- ).map(([key, label]) => (
739
- <button
740
- key={key}
741
- onClick={() => setResponseStyle(key)}
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
- removeQuestion(q.id, topicId);
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
  >