create-interview-cockpit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +19 -0
  3. package/template/client/package.json +3 -0
  4. package/template/client/src/App.tsx +17 -0
  5. package/template/client/src/api.ts +135 -0
  6. package/template/client/src/components/AiSettingsModal.tsx +218 -4
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +110 -27
  9. package/template/client/src/components/ChatView.tsx +69 -4
  10. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  13. package/template/client/src/components/DocRefModal.tsx +502 -0
  14. package/template/client/src/components/FileAttachments.tsx +109 -9
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/MarkdownRenderer.tsx +205 -2
  18. package/template/client/src/components/Sidebar.tsx +213 -127
  19. package/template/client/src/components/TextAnnotator.tsx +8 -15
  20. package/template/client/src/components/VizCraftEmbed.tsx +162 -19
  21. package/template/client/src/store.ts +201 -0
  22. package/template/client/src/types.ts +8 -0
  23. package/template/cockpit.json +1 -1
  24. package/template/package.json +1 -1
  25. package/template/server/src/google-drive.ts +109 -1
  26. package/template/server/src/index.ts +1107 -46
  27. package/template/server/src/storage.ts +263 -2
@@ -1,6 +1,14 @@
1
- import { memo } from "react";
1
+ import { memo, useState, useCallback } from "react";
2
2
  import type { UIMessage } from "ai";
3
- import { User, Bot } from "lucide-react";
3
+ import {
4
+ User,
5
+ Bot,
6
+ Copy,
7
+ Check,
8
+ ChevronDown,
9
+ ChevronRight,
10
+ Brain,
11
+ } from "lucide-react";
4
12
  import TextAnnotator from "./TextAnnotator";
5
13
  import type { Annotation } from "../types";
6
14
 
@@ -11,9 +19,12 @@ interface Props {
11
19
  onAnnotationUpdate?: (annotation: Annotation) => void;
12
20
  bookmarkedBlockIndex?: number;
13
21
  onSetBookmark?: (messageId: string, blockIndex: number) => void;
14
- responseLength?: string;
15
- responseStyle?: string;
16
- responseAudience?: string;
22
+ preferenceSuffix?: string;
23
+ onSpecRefined?: (
24
+ messageId: string,
25
+ originalSpec: string,
26
+ newSpec: string,
27
+ ) => void;
17
28
  }
18
29
 
19
30
  function getTextContent(message: UIMessage): string {
@@ -26,6 +37,47 @@ function getTextContent(message: UIMessage): string {
26
37
  return "";
27
38
  }
28
39
 
40
+ function getReasoningContent(message: UIMessage): string {
41
+ if (!message.parts) return "";
42
+ return message.parts
43
+ .filter(
44
+ (p): p is { type: "reasoning"; reasoning: string } =>
45
+ p.type === "reasoning" && typeof (p as any).reasoning === "string",
46
+ )
47
+ .map((p) => p.reasoning)
48
+ .join("\n\n");
49
+ }
50
+
51
+ function ThinkingBlock({ reasoning }: { reasoning: string }) {
52
+ const [open, setOpen] = useState(false);
53
+ const wordCount = reasoning.trim().split(/\s+/).length;
54
+ return (
55
+ <div className="mb-2 rounded-lg border border-slate-700/60 bg-slate-800/40 text-xs overflow-hidden">
56
+ <button
57
+ type="button"
58
+ onClick={() => setOpen((v) => !v)}
59
+ className="w-full flex items-center gap-1.5 px-3 py-2 text-slate-500 hover:text-slate-300 transition-colors text-left"
60
+ >
61
+ <Brain className="w-3 h-3 shrink-0 text-violet-400" />
62
+ <span className="text-violet-400/80 font-medium">Thinking</span>
63
+ <span className="text-slate-600 ml-auto mr-1">
64
+ {wordCount.toLocaleString()} words
65
+ </span>
66
+ {open ? (
67
+ <ChevronDown className="w-3 h-3 shrink-0" />
68
+ ) : (
69
+ <ChevronRight className="w-3 h-3 shrink-0" />
70
+ )}
71
+ </button>
72
+ {open && (
73
+ <div className="px-3 pb-3 pt-0 text-slate-500 whitespace-pre-wrap leading-relaxed border-t border-slate-700/40 max-h-80 overflow-y-auto">
74
+ {reasoning}
75
+ </div>
76
+ )}
77
+ </div>
78
+ );
79
+ }
80
+
29
81
  const ChatMessage = memo(function ChatMessage({
30
82
  message,
31
83
  annotations = [],
@@ -33,15 +85,24 @@ const ChatMessage = memo(function ChatMessage({
33
85
  onAnnotationUpdate,
34
86
  bookmarkedBlockIndex,
35
87
  onSetBookmark,
36
- responseLength,
37
- responseStyle,
38
- responseAudience,
88
+ preferenceSuffix,
89
+ onSpecRefined,
39
90
  }: Props) {
40
91
  const isUser = message.role === "user";
41
92
  const content = getTextContent(message);
93
+ const reasoning = !isUser ? getReasoningContent(message) : "";
94
+ const [copied, setCopied] = useState(false);
95
+
96
+ const handleCopy = useCallback(() => {
97
+ if (!content) return;
98
+ navigator.clipboard.writeText(content).then(() => {
99
+ setCopied(true);
100
+ setTimeout(() => setCopied(false), 1500);
101
+ });
102
+ }, [content]);
42
103
 
43
104
  return (
44
- <div className="flex gap-3 animate-fadeIn">
105
+ <div className="group flex gap-3 animate-fadeIn">
45
106
  <div
46
107
  className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5 ${
47
108
  isUser
@@ -56,8 +117,23 @@ const ChatMessage = memo(function ChatMessage({
56
117
  )}
57
118
  </div>
58
119
  <div className="min-w-0 flex-1">
59
- <div className="text-[10px] font-medium text-slate-600 mb-1">
60
- {isUser ? "You" : "Coach"}
120
+ <div className="flex items-center gap-2 mb-1">
121
+ <div className="text-[10px] font-medium text-slate-600">
122
+ {isUser ? "You" : "Coach"}
123
+ </div>
124
+ {content && (
125
+ <button
126
+ onClick={handleCopy}
127
+ className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded text-slate-500 hover:text-slate-300 hover:bg-slate-700"
128
+ title="Copy message"
129
+ >
130
+ {copied ? (
131
+ <Check className="w-3 h-3 text-cyan-400" />
132
+ ) : (
133
+ <Copy className="w-3 h-3" />
134
+ )}
135
+ </button>
136
+ )}
61
137
  </div>
62
138
  <div className="text-sm leading-relaxed text-slate-200">
63
139
  {isUser ? (
@@ -89,22 +165,29 @@ const ChatMessage = memo(function ChatMessage({
89
165
  )}
90
166
  </div>
91
167
  ) : (
92
- <TextAnnotator
93
- content={content}
94
- messageId={message.id}
95
- annotations={annotations}
96
- onAnnotationCreate={onAnnotationCreate ?? (() => {})}
97
- onAnnotationUpdate={onAnnotationUpdate ?? (() => {})}
98
- bookmarkedBlockIndex={bookmarkedBlockIndex}
99
- onBookmarkBlock={
100
- onSetBookmark
101
- ? (idx) => onSetBookmark(message.id, idx)
102
- : undefined
103
- }
104
- responseLength={responseLength}
105
- responseStyle={responseStyle}
106
- responseAudience={responseAudience}
107
- />
168
+ <>
169
+ {reasoning && <ThinkingBlock reasoning={reasoning} />}
170
+ <TextAnnotator
171
+ content={content}
172
+ messageId={message.id}
173
+ annotations={annotations}
174
+ onAnnotationCreate={onAnnotationCreate ?? (() => {})}
175
+ onAnnotationUpdate={onAnnotationUpdate ?? (() => {})}
176
+ bookmarkedBlockIndex={bookmarkedBlockIndex}
177
+ onBookmarkBlock={
178
+ onSetBookmark
179
+ ? (idx) => onSetBookmark(message.id, idx)
180
+ : undefined
181
+ }
182
+ preferenceSuffix={preferenceSuffix}
183
+ onSpecRefined={
184
+ onSpecRefined
185
+ ? (orig, refined) =>
186
+ onSpecRefined(message.id, orig, refined)
187
+ : undefined
188
+ }
189
+ />
190
+ </>
108
191
  )}
109
192
  </div>
110
193
  </div>
@@ -123,12 +123,14 @@ export default function ChatView({ question }: Props) {
123
123
  refreshCurrentQuestion,
124
124
  uploadQuestionFiles,
125
125
  removeQuestionFile,
126
+ linkFileToQuestion,
126
127
  clearMessages,
127
128
  updateQuestionSystemContext,
128
129
  topics,
129
130
  selectedTopicId,
130
131
  codeSnippets,
131
132
  aiSettings,
133
+ setLivePreferenceSuffix,
132
134
  } = useStore();
133
135
  const [showContext, setShowContext] = useState(false);
134
136
  const [systemContext, setSystemContext] = useState(
@@ -142,7 +144,9 @@ export default function ChatView({ question }: Props) {
142
144
  Object.entries(aiSettings.promptGroups).map(([k, g]) => [k, g.default]),
143
145
  ),
144
146
  );
145
- const [alwaysSendPrefs, setAlwaysSendPrefs] = useState(false);
147
+ const [alwaysSendPrefs, setAlwaysSendPrefs] = useState(
148
+ () => aiSettings.alwaysSendPrefsDefault ?? false,
149
+ );
146
150
  const [annotations, setAnnotations] = useState<Annotation[]>(
147
151
  question.annotations ?? [],
148
152
  );
@@ -458,6 +462,64 @@ export default function ChatView({ question }: Props) {
458
462
  [question.id],
459
463
  );
460
464
 
465
+ // Persist a refined viz spec back to the server so changes survive refresh
466
+ const handleSpecRefined = useCallback(
467
+ (messageId: string, originalSpec: string, newSpec: string) => {
468
+ setMessages((prev) =>
469
+ prev.map((m) => {
470
+ if (m.id !== messageId) return m;
471
+ const updatedParts = (m.parts ?? []).map((p) => {
472
+ if (p.type !== "text") return p;
473
+ const tp = p as { type: "text"; text: string };
474
+ return {
475
+ ...tp,
476
+ text: tp.text.split(originalSpec).join(newSpec),
477
+ };
478
+ });
479
+ return { ...m, parts: updatedParts };
480
+ }),
481
+ );
482
+ // Persist to server — rebuild server-side Message[] from updated UIMessages
483
+ setMessages((prev) => {
484
+ const serverMessages = prev.map((m) => ({
485
+ id: m.id,
486
+ role: m.role,
487
+ content: (m.parts ?? [])
488
+ .filter(
489
+ (p): p is { type: "text"; text: string } => p.type === "text",
490
+ )
491
+ .map((p) => p.text)
492
+ .join(""),
493
+ parts: m.parts,
494
+ }));
495
+ fetch(`/api/questions/${question.id}`, {
496
+ method: "PATCH",
497
+ headers: { "Content-Type": "application/json" },
498
+ body: JSON.stringify({ messages: serverMessages }),
499
+ }).catch((err) =>
500
+ console.error("Failed to persist refined spec:", err),
501
+ );
502
+ return prev; // no further state change
503
+ });
504
+ },
505
+ [question.id, setMessages],
506
+ );
507
+
508
+ // Build a preference text for annotation dialogs from ALL current group selections
509
+ const annotationPrefs = useMemo(() => {
510
+ const parts: string[] = [];
511
+ for (const [key, sel] of Object.entries(groupSelections)) {
512
+ const prompt = aiSettings.promptGroups[key]?.options[sel];
513
+ if (prompt) parts.push(prompt);
514
+ }
515
+ return parts.join(" ");
516
+ }, [groupSelections, aiSettings.promptGroups]);
517
+
518
+ // Keep the store in sync so FileViewerModal can read the current preference string
519
+ useEffect(() => {
520
+ setLivePreferenceSuffix(annotationPrefs);
521
+ }, [annotationPrefs, setLivePreferenceSuffix]);
522
+
461
523
  // Group annotations by message id so we don't run filter() inside render,
462
524
  // which would produce a new array reference on every ChatView re-render and
463
525
  // defeat React.memo on ChatMessage.
@@ -608,9 +670,8 @@ export default function ChatView({ question }: Props) {
608
670
  : undefined
609
671
  }
610
672
  onSetBookmark={handleSetBookmark}
611
- responseLength={groupSelections["length"]}
612
- responseStyle={groupSelections["style"]}
613
- responseAudience={groupSelections["audience"]}
673
+ preferenceSuffix={annotationPrefs}
674
+ onSpecRefined={handleSpecRefined}
614
675
  />
615
676
  </div>
616
677
  ))}
@@ -684,6 +745,10 @@ export default function ChatView({ question }: Props) {
684
745
  files={question.contextFiles || []}
685
746
  onUpload={(files) => uploadQuestionFiles(question.id, files)}
686
747
  onRemove={(fileId) => removeQuestionFile(question.id, fileId)}
748
+ onLink={(fileId, originalName) =>
749
+ linkFileToQuestion(question.id, fileId, originalName)
750
+ }
751
+ downloadBase={`/api/questions/${question.id}/context-files`}
687
752
  label="question"
688
753
  compact
689
754
  />
@@ -15,6 +15,13 @@ import {
15
15
  MinusSquare,
16
16
  Eye,
17
17
  Scissors,
18
+ Terminal,
19
+ Sparkles,
20
+ Plus,
21
+ Play,
22
+ Trash2,
23
+ Server,
24
+ Pencil,
18
25
  } from "lucide-react";
19
26
 
20
27
  // ─── Tree data structure ─────────────────────────────────
@@ -219,8 +226,15 @@ export default function CodeContextPanel() {
219
226
  codeSnippets,
220
227
  removeSnippet,
221
228
  clearSnippets,
229
+ openCodeRunner,
230
+ openSandbox,
231
+ removeQuestionFile,
232
+ renameContextFile,
222
233
  } = useStore();
223
234
 
235
+ const [renamingId, setRenamingId] = useState<string | null>(null);
236
+ const [renameValue, setRenameValue] = useState("");
237
+
224
238
  const [search, setSearch] = useState("");
225
239
  const [selectedFiles, setSelectedFiles] = useState<string[]>(
226
240
  currentQuestion?.codeContextFiles || [],
@@ -459,6 +473,289 @@ export default function CodeContextPanel() {
459
473
  })}
460
474
  </div>
461
475
 
476
+ {/* ── My Code section ─────────────────────────────────── */}
477
+ {currentQuestion && (
478
+ <div className="border-t border-slate-800 px-3 py-2">
479
+ <div className="flex items-center justify-between mb-1">
480
+ <div className="flex items-center gap-1">
481
+ <Terminal className="w-3 h-3 text-emerald-400/70" />
482
+ <span className="text-[10px] uppercase tracking-wider text-slate-600">
483
+ My Code (
484
+ {
485
+ (currentQuestion.contextFiles || []).filter(
486
+ (f) => f.origin === "user",
487
+ ).length
488
+ }
489
+ )
490
+ </span>
491
+ </div>
492
+ <button
493
+ onClick={() => openCodeRunner()}
494
+ className="p-0.5 rounded text-slate-600 hover:text-emerald-400 hover:bg-slate-700 transition-colors"
495
+ title="Open Code Runner"
496
+ >
497
+ <Plus className="w-3.5 h-3.5" />
498
+ </button>
499
+ </div>
500
+ <div className="space-y-0.5 max-h-32 overflow-y-auto">
501
+ {(currentQuestion.contextFiles || [])
502
+ .filter((f) => f.origin === "user")
503
+ .map((cf) => (
504
+ <div
505
+ key={cf.id}
506
+ className="flex items-center gap-1 text-xs bg-emerald-500/10 border border-emerald-500/20 rounded px-1.5 py-1 group"
507
+ >
508
+ <span
509
+ className="text-emerald-400 font-medium truncate flex-1"
510
+ title={cf.label || cf.originalName}
511
+ >
512
+ {cf.label || cf.originalName}
513
+ </span>
514
+ <button
515
+ onClick={async () => {
516
+ const content = await fetch(
517
+ `/api/context-files/${cf.id}/content`,
518
+ )
519
+ .then((r) => r.json())
520
+ .then((d) => d.content);
521
+ openCodeRunner(content, cf.language ?? "typescript");
522
+ }}
523
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-emerald-400 transition-all"
524
+ title="Open in Code Runner"
525
+ >
526
+ <Play className="w-3 h-3" />
527
+ </button>
528
+ <button
529
+ onClick={() =>
530
+ removeQuestionFile(currentQuestion.id, cf.id)
531
+ }
532
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
533
+ title="Remove"
534
+ >
535
+ <Trash2 className="w-3 h-3" />
536
+ </button>
537
+ </div>
538
+ ))}
539
+ {(currentQuestion.contextFiles || []).filter(
540
+ (f) => f.origin === "user",
541
+ ).length === 0 && (
542
+ <p className="text-[10px] text-slate-700 italic">
543
+ Save code from the runner to see it here
544
+ </p>
545
+ )}
546
+ </div>
547
+ </div>
548
+ )}
549
+
550
+ {/* ── AI Generated section ──────────────────────────── */}
551
+ {currentQuestion && (
552
+ <div className="border-t border-slate-800 px-3 py-2">
553
+ <div className="flex items-center gap-1 mb-1">
554
+ <Sparkles className="w-3 h-3 text-violet-400/70" />
555
+ <span className="text-[10px] uppercase tracking-wider text-slate-600">
556
+ AI Generated (
557
+ {
558
+ (currentQuestion.contextFiles || []).filter(
559
+ (f) => f.origin === "ai",
560
+ ).length
561
+ }
562
+ )
563
+ </span>
564
+ </div>
565
+ <div className="space-y-0.5 max-h-32 overflow-y-auto">
566
+ {(currentQuestion.contextFiles || [])
567
+ .filter((f) => f.origin === "ai")
568
+ .map((cf) => (
569
+ <div
570
+ key={cf.id}
571
+ className="flex items-center gap-1 text-xs bg-violet-500/10 border border-violet-500/20 rounded px-1.5 py-1 group"
572
+ >
573
+ <span
574
+ className="text-violet-300 font-medium truncate flex-1"
575
+ title={cf.label || cf.originalName}
576
+ >
577
+ {cf.label || cf.originalName}
578
+ </span>
579
+ <button
580
+ onClick={async () => {
581
+ const content = await fetch(
582
+ `/api/context-files/${cf.id}/content`,
583
+ )
584
+ .then((r) => r.json())
585
+ .then((d) => d.content);
586
+ openCodeRunner(content, cf.language ?? "typescript");
587
+ }}
588
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-violet-400 transition-all"
589
+ title="Open in Code Runner"
590
+ >
591
+ <Play className="w-3 h-3" />
592
+ </button>
593
+ <button
594
+ onClick={() =>
595
+ removeQuestionFile(currentQuestion.id, cf.id)
596
+ }
597
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
598
+ title="Remove"
599
+ >
600
+ <Trash2 className="w-3 h-3" />
601
+ </button>
602
+ </div>
603
+ ))}
604
+ {(currentQuestion.contextFiles || []).filter(
605
+ (f) => f.origin === "ai",
606
+ ).length === 0 && (
607
+ <p className="text-[10px] text-slate-700 italic">
608
+ Save AI code blocks to see them here
609
+ </p>
610
+ )}
611
+ </div>
612
+ </div>
613
+ )}
614
+
615
+ {/* ── Sandboxes section ───────────────────────── */}
616
+ {currentQuestion && (
617
+ <div className="border-t border-slate-800 px-3 py-2">
618
+ <div className="flex items-center gap-1 mb-1">
619
+ <Server className="w-3 h-3 text-slate-500/70" />
620
+ <span className="text-[10px] uppercase tracking-wider text-slate-600">
621
+ Sandboxes (
622
+ {
623
+ (currentQuestion.contextFiles || []).filter(
624
+ (f) => f.origin === "sandbox",
625
+ ).length
626
+ }
627
+ )
628
+ </span>
629
+ </div>
630
+ <div className="space-y-0.5 max-h-32 overflow-y-auto">
631
+ {(currentQuestion.contextFiles || [])
632
+ .filter((f) => f.origin === "sandbox")
633
+ .map((cf) => (
634
+ <div
635
+ key={cf.id}
636
+ className="flex items-center gap-1 text-xs bg-slate-500/10 border border-slate-500/20 rounded px-1.5 py-1 group"
637
+ >
638
+ {renamingId !== cf.id && (
639
+ <span
640
+ className="text-slate-300 font-medium truncate flex-1"
641
+ title={cf.label || cf.originalName}
642
+ >
643
+ {cf.label || cf.originalName}
644
+ </span>
645
+ )}
646
+ {renamingId === cf.id ? (
647
+ <>
648
+ <input
649
+ autoFocus
650
+ value={renameValue}
651
+ onChange={(e) => setRenameValue(e.target.value)}
652
+ onKeyDown={async (e) => {
653
+ if (e.key === "Enter") {
654
+ e.preventDefault();
655
+ if (renameValue.trim()) {
656
+ await renameContextFile(
657
+ currentQuestion.id,
658
+ cf.id,
659
+ renameValue.trim(),
660
+ );
661
+ }
662
+ setRenamingId(null);
663
+ } else if (e.key === "Escape") {
664
+ setRenamingId(null);
665
+ }
666
+ }}
667
+ className="w-28 bg-slate-900 border border-violet-600/50 rounded px-1.5 py-0.5 text-[11px] text-slate-200 outline-none focus:border-violet-500 shrink-0"
668
+ />
669
+ <button
670
+ onClick={async () => {
671
+ if (renameValue.trim()) {
672
+ await renameContextFile(
673
+ currentQuestion.id,
674
+ cf.id,
675
+ renameValue.trim(),
676
+ );
677
+ }
678
+ setRenamingId(null);
679
+ }}
680
+ className="shrink-0 p-0.5 rounded text-violet-400 hover:bg-violet-600/20 transition-colors"
681
+ title="Confirm"
682
+ >
683
+ <Check className="w-3 h-3" />
684
+ </button>
685
+ <button
686
+ onClick={() => setRenamingId(null)}
687
+ className="shrink-0 p-0.5 rounded text-slate-500 hover:text-slate-300 transition-colors"
688
+ title="Cancel"
689
+ >
690
+ <X className="w-3 h-3" />
691
+ </button>
692
+ </>
693
+ ) : (
694
+ <>
695
+ <button
696
+ onClick={() => {
697
+ setRenamingId(cf.id);
698
+ setRenameValue(cf.label || cf.originalName);
699
+ }}
700
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-violet-300 transition-all"
701
+ title="Rename"
702
+ >
703
+ <Pencil className="w-3 h-3" />
704
+ </button>
705
+ <button
706
+ onClick={async () => {
707
+ try {
708
+ const raw = await fetch(
709
+ `/api/context-files/${cf.id}/content`,
710
+ )
711
+ .then((r) => r.json())
712
+ .then((d) => d.content as string);
713
+ const parsed = JSON.parse(raw) as {
714
+ serverCode: string;
715
+ serverLang: string;
716
+ clientCode: string;
717
+ clientLang: string;
718
+ };
719
+ openSandbox(
720
+ parsed.serverCode,
721
+ parsed.serverLang,
722
+ parsed.clientCode,
723
+ parsed.clientLang,
724
+ cf.id,
725
+ );
726
+ } catch {
727
+ /* malformed — ignore */
728
+ }
729
+ }}
730
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-violet-400 transition-all"
731
+ title="Open in Sandbox"
732
+ >
733
+ <Play className="w-3 h-3" />
734
+ </button>
735
+ <button
736
+ onClick={() =>
737
+ removeQuestionFile(currentQuestion.id, cf.id)
738
+ }
739
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all"
740
+ title="Remove"
741
+ >
742
+ <Trash2 className="w-3 h-3" />
743
+ </button>
744
+ </>
745
+ )}
746
+ </div>
747
+ ))}
748
+ {(currentQuestion.contextFiles || []).filter(
749
+ (f) => f.origin === "sandbox",
750
+ ).length === 0 && (
751
+ <p className="text-[10px] text-slate-700 italic">
752
+ Save a sandbox to see it here
753
+ </p>
754
+ )}
755
+ </div>
756
+ </div>
757
+ )}
758
+
462
759
  {viewingFile && (
463
760
  <FileViewerModal
464
761
  filePath={viewingFile}