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.
- package/package.json +1 -1
- package/template/client/package-lock.json +19 -0
- package/template/client/package.json +3 -0
- package/template/client/src/App.tsx +17 -0
- package/template/client/src/api.ts +135 -0
- package/template/client/src/components/AiSettingsModal.tsx +218 -4
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +110 -27
- package/template/client/src/components/ChatView.tsx +69 -4
- package/template/client/src/components/CodeContextPanel.tsx +297 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
- package/template/client/src/components/DocRefModal.tsx +502 -0
- package/template/client/src/components/FileAttachments.tsx +109 -9
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/MarkdownRenderer.tsx +205 -2
- package/template/client/src/components/Sidebar.tsx +213 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +162 -19
- package/template/client/src/store.ts +201 -0
- package/template/client/src/types.ts +8 -0
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1107 -46
- 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 {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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="
|
|
60
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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(
|
|
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
|
-
|
|
612
|
-
|
|
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}
|