create-interview-cockpit 0.14.0 → 0.15.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/src/api.ts +39 -0
- package/template/client/src/browserSecurityTemplates.ts +3242 -0
- package/template/client/src/components/BrowserSecurityLabModal.tsx +1510 -0
- package/template/client/src/components/CodeRunnerModal.tsx +406 -55
- package/template/client/src/components/LabsPanel.tsx +57 -7
- package/template/client/src/components/LinkedConvosPicker.tsx +121 -63
- package/template/client/src/components/Sidebar.tsx +113 -0
- package/template/client/src/store.ts +15 -1
- package/template/client/src/types.ts +2 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/server/src/google-drive.ts +2 -0
- package/template/server/src/index.ts +48 -1
- package/template/server/src/storage.ts +2 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
parseFrontendLabWorkspace,
|
|
6
6
|
ISOLATED_MODULE_FEDERATION_LAB,
|
|
7
7
|
} from "../reactLab";
|
|
8
|
+
import { BROWSER_SECURITY_TEMPLATES } from "../browserSecurityTemplates";
|
|
8
9
|
import type { ContextFile } from "../types";
|
|
9
10
|
import {
|
|
10
11
|
Plus,
|
|
@@ -21,12 +22,14 @@ import {
|
|
|
21
22
|
LinkIcon,
|
|
22
23
|
Link2Off,
|
|
23
24
|
Network,
|
|
25
|
+
Shield,
|
|
24
26
|
} from "lucide-react";
|
|
25
27
|
|
|
26
28
|
// ─── Helpers ─────────────────────────────────────────────
|
|
27
29
|
|
|
28
30
|
const LAB_ORIGINS = new Set([
|
|
29
31
|
"sandbox",
|
|
32
|
+
"browser-security",
|
|
30
33
|
"infra",
|
|
31
34
|
"react",
|
|
32
35
|
"nextjs",
|
|
@@ -239,19 +242,50 @@ export default function LabsPanel() {
|
|
|
239
242
|
parsed.clientCode,
|
|
240
243
|
parsed.clientLang,
|
|
241
244
|
cf.id,
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
245
|
+
{
|
|
246
|
+
label: cf.label || cf.originalName,
|
|
247
|
+
origin:
|
|
248
|
+
cf.origin === "browser-security" ? "browser-security" : "sandbox",
|
|
249
|
+
...(parsed.clientType
|
|
250
|
+
? {
|
|
251
|
+
clientType: parsed.clientType,
|
|
252
|
+
reactFiles: parsed.reactFiles,
|
|
253
|
+
reactActiveFile: parsed.reactActiveFile,
|
|
254
|
+
}
|
|
255
|
+
: {}),
|
|
256
|
+
},
|
|
249
257
|
);
|
|
250
258
|
} catch {
|
|
251
259
|
/* ignore */
|
|
252
260
|
}
|
|
253
261
|
};
|
|
254
262
|
|
|
263
|
+
const openBrowserSecurityTemplate = (templateId: string) => {
|
|
264
|
+
const template = BROWSER_SECURITY_TEMPLATES.find(
|
|
265
|
+
(item) => item.id === templateId,
|
|
266
|
+
);
|
|
267
|
+
if (!template) return;
|
|
268
|
+
|
|
269
|
+
openSandbox(
|
|
270
|
+
template.serverCode,
|
|
271
|
+
"javascript",
|
|
272
|
+
template.clientCode,
|
|
273
|
+
"javascript",
|
|
274
|
+
undefined,
|
|
275
|
+
{
|
|
276
|
+
label: template.label,
|
|
277
|
+
origin: "browser-security",
|
|
278
|
+
...(template.clientType
|
|
279
|
+
? {
|
|
280
|
+
clientType: template.clientType,
|
|
281
|
+
reactFiles: template.reactFiles,
|
|
282
|
+
reactActiveFile: template.reactActiveFile,
|
|
283
|
+
}
|
|
284
|
+
: {}),
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
255
289
|
const openInfraFile = async (cf: ContextFile) => {
|
|
256
290
|
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
257
291
|
.then((r) => r.json())
|
|
@@ -531,6 +565,22 @@ export default function LabsPanel() {
|
|
|
531
565
|
accentClass="text-slate-300"
|
|
532
566
|
bgClass="bg-slate-500/10 border border-slate-500/20"
|
|
533
567
|
/>
|
|
568
|
+
<Section
|
|
569
|
+
title="Browser Security"
|
|
570
|
+
icon={Shield}
|
|
571
|
+
iconColor="text-cyan-400/70"
|
|
572
|
+
origin="browser-security"
|
|
573
|
+
emptyText="Save a browser security lab to reopen it here"
|
|
574
|
+
newLabMenu={BROWSER_SECURITY_TEMPLATES.map((template) => ({
|
|
575
|
+
label: template.label,
|
|
576
|
+
description: template.description,
|
|
577
|
+
onClick: () => openBrowserSecurityTemplate(template.id),
|
|
578
|
+
}))}
|
|
579
|
+
onOpen={openSandboxFile}
|
|
580
|
+
openTitle="Open in Browser Security Lab"
|
|
581
|
+
accentClass="text-cyan-200"
|
|
582
|
+
bgClass="bg-cyan-500/10 border border-cyan-500/20"
|
|
583
|
+
/>
|
|
534
584
|
<Section
|
|
535
585
|
title="Infra Labs"
|
|
536
586
|
icon={Globe}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { X, MessageSquare, Check, Loader2 } from "lucide-react";
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { X, MessageSquare, Check, Loader2, Search } from "lucide-react";
|
|
3
3
|
import type { Question } from "../types";
|
|
4
4
|
import { useStore } from "../store";
|
|
5
5
|
|
|
@@ -14,20 +14,10 @@ export default function LinkedConvosPicker({
|
|
|
14
14
|
topicId,
|
|
15
15
|
onClose,
|
|
16
16
|
}: Props) {
|
|
17
|
-
const { linkConversation, unlinkConversation } =
|
|
18
|
-
|
|
19
|
-
const [loading, setLoading] = useState(true);
|
|
17
|
+
const { linkConversation, unlinkConversation, topics, questionsByTopic } =
|
|
18
|
+
useStore();
|
|
20
19
|
const [pending, setPending] = useState<string | null>(null);
|
|
21
|
-
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
fetch(`/api/topics/${topicId}/questions`)
|
|
24
|
-
.then((r) => r.json())
|
|
25
|
-
.then((qs: Question[]) => {
|
|
26
|
-
// Exclude the current question itself
|
|
27
|
-
setSiblings(qs.filter((q2) => q2.id !== question.id));
|
|
28
|
-
})
|
|
29
|
-
.finally(() => setLoading(false));
|
|
30
|
-
}, [topicId, question.id]);
|
|
20
|
+
const [search, setSearch] = useState("");
|
|
31
21
|
|
|
32
22
|
const linked = new Set(question.linkedConversationIds ?? []);
|
|
33
23
|
|
|
@@ -44,9 +34,35 @@ export default function LinkedConvosPicker({
|
|
|
44
34
|
}
|
|
45
35
|
};
|
|
46
36
|
|
|
37
|
+
// Current topic first, then the rest alphabetically
|
|
38
|
+
const grouped = useMemo(() => {
|
|
39
|
+
const q = search.toLowerCase().trim();
|
|
40
|
+
const sortedTopics = [
|
|
41
|
+
topics.find((t) => t.id === topicId),
|
|
42
|
+
...topics.filter((t) => t.id !== topicId),
|
|
43
|
+
].filter(Boolean);
|
|
44
|
+
|
|
45
|
+
return sortedTopics
|
|
46
|
+
.map((topic) => ({
|
|
47
|
+
topic: topic!,
|
|
48
|
+
questions: (questionsByTopic[topic!.id] ?? []).filter(
|
|
49
|
+
(q2) =>
|
|
50
|
+
q2.id !== question.id &&
|
|
51
|
+
(q ? q2.title.toLowerCase().includes(q) : true),
|
|
52
|
+
),
|
|
53
|
+
}))
|
|
54
|
+
.filter((g) => g.questions.length > 0);
|
|
55
|
+
}, [topics, questionsByTopic, topicId, question.id, search]);
|
|
56
|
+
|
|
47
57
|
return (
|
|
48
|
-
<div
|
|
49
|
-
|
|
58
|
+
<div
|
|
59
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
|
60
|
+
onClick={onClose}
|
|
61
|
+
>
|
|
62
|
+
<div
|
|
63
|
+
className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[75vh]"
|
|
64
|
+
onClick={(e) => e.stopPropagation()}
|
|
65
|
+
>
|
|
50
66
|
{/* Header */}
|
|
51
67
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-700 shrink-0">
|
|
52
68
|
<MessageSquare className="w-4 h-4 text-violet-400 shrink-0" />
|
|
@@ -61,58 +77,100 @@ export default function LinkedConvosPicker({
|
|
|
61
77
|
</button>
|
|
62
78
|
</div>
|
|
63
79
|
|
|
80
|
+
{/* Search */}
|
|
81
|
+
<div className="px-3 py-2 border-b border-slate-800 shrink-0">
|
|
82
|
+
<div className="flex items-center gap-2 bg-slate-800 rounded-lg px-2.5 py-1.5">
|
|
83
|
+
<Search className="w-3 h-3 text-slate-500 shrink-0" />
|
|
84
|
+
<input
|
|
85
|
+
autoFocus
|
|
86
|
+
value={search}
|
|
87
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
88
|
+
placeholder="Search conversations…"
|
|
89
|
+
className="flex-1 bg-transparent text-xs text-slate-300 placeholder-slate-600 focus:outline-none"
|
|
90
|
+
/>
|
|
91
|
+
{search && (
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => setSearch("")}
|
|
94
|
+
className="text-slate-600 hover:text-slate-400 transition-colors"
|
|
95
|
+
>
|
|
96
|
+
<X className="w-3 h-3" />
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
64
102
|
{/* Body */}
|
|
65
|
-
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-
|
|
66
|
-
{
|
|
67
|
-
<div className="flex items-center justify-center py-8">
|
|
68
|
-
<Loader2 className="w-5 h-5 text-violet-400 animate-spin" />
|
|
69
|
-
</div>
|
|
70
|
-
)}
|
|
71
|
-
{!loading && siblings.length === 0 && (
|
|
103
|
+
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-4">
|
|
104
|
+
{grouped.length === 0 && (
|
|
72
105
|
<p className="text-xs text-slate-500 text-center py-8">
|
|
73
|
-
|
|
106
|
+
{search
|
|
107
|
+
? "No conversations match your search."
|
|
108
|
+
: "No other conversations yet."}
|
|
74
109
|
</p>
|
|
75
110
|
)}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
onClick={() => toggle(sibling.id)}
|
|
85
|
-
disabled={isPending}
|
|
86
|
-
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
|
87
|
-
isLinked
|
|
88
|
-
? "bg-violet-500/15 border border-violet-500/30 hover:bg-violet-500/20"
|
|
89
|
-
: "bg-slate-800/50 border border-transparent hover:bg-slate-800"
|
|
111
|
+
|
|
112
|
+
{grouped.map(({ topic, questions }) => (
|
|
113
|
+
<div key={topic.id}>
|
|
114
|
+
{/* Topic group header */}
|
|
115
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
116
|
+
<span
|
|
117
|
+
className={`text-[10px] font-semibold uppercase tracking-wide shrink-0 ${
|
|
118
|
+
topic.id === topicId ? "text-violet-400" : "text-slate-500"
|
|
90
119
|
}`}
|
|
91
120
|
>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
{topic.name}
|
|
122
|
+
</span>
|
|
123
|
+
{topic.id === topicId && (
|
|
124
|
+
<span className="text-[9px] bg-violet-500/20 text-violet-400 px-1.5 py-0.5 rounded shrink-0">
|
|
125
|
+
current
|
|
126
|
+
</span>
|
|
127
|
+
)}
|
|
128
|
+
<div className="flex-1 h-px bg-slate-800" />
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div className="space-y-1">
|
|
132
|
+
{questions.map((sibling) => {
|
|
133
|
+
const isLinked = linked.has(sibling.id);
|
|
134
|
+
const isPending = pending === sibling.id;
|
|
135
|
+
const msgCount = sibling.messages?.length ?? 0;
|
|
136
|
+
return (
|
|
137
|
+
<button
|
|
138
|
+
key={sibling.id}
|
|
139
|
+
onClick={() => toggle(sibling.id)}
|
|
140
|
+
disabled={isPending}
|
|
141
|
+
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
|
142
|
+
isLinked
|
|
143
|
+
? "bg-violet-500/15 border border-violet-500/30 hover:bg-violet-500/20"
|
|
144
|
+
: "bg-slate-800/50 border border-transparent hover:bg-slate-800"
|
|
145
|
+
}`}
|
|
146
|
+
>
|
|
147
|
+
<div
|
|
148
|
+
className={`w-4 h-4 rounded border shrink-0 flex items-center justify-center transition-colors ${
|
|
149
|
+
isLinked
|
|
150
|
+
? "bg-violet-500 border-violet-500"
|
|
151
|
+
: "border-slate-600"
|
|
152
|
+
}`}
|
|
153
|
+
>
|
|
154
|
+
{isPending ? (
|
|
155
|
+
<Loader2 className="w-2.5 h-2.5 animate-spin text-white" />
|
|
156
|
+
) : isLinked ? (
|
|
157
|
+
<Check className="w-2.5 h-2.5 text-white" />
|
|
158
|
+
) : null}
|
|
159
|
+
</div>
|
|
160
|
+
<div className="flex-1 min-w-0">
|
|
161
|
+
<p className="text-xs font-medium text-slate-200 truncate">
|
|
162
|
+
{sibling.title}
|
|
163
|
+
</p>
|
|
164
|
+
<p className="text-[10px] text-slate-500 mt-0.5">
|
|
165
|
+
{msgCount} message{msgCount !== 1 ? "s" : ""}
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
</button>
|
|
169
|
+
);
|
|
170
|
+
})}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
))}
|
|
116
174
|
</div>
|
|
117
175
|
|
|
118
176
|
{/* Footer hint */}
|
|
@@ -3,6 +3,7 @@ import { useStore } from "../store";
|
|
|
3
3
|
import type { Question } from "../types";
|
|
4
4
|
import FileAttachments from "./FileAttachments";
|
|
5
5
|
import WorkspaceSwitcher from "./WorkspaceSwitcher";
|
|
6
|
+
|
|
6
7
|
import {
|
|
7
8
|
ChevronRight,
|
|
8
9
|
ChevronDown,
|
|
@@ -20,10 +21,46 @@ import {
|
|
|
20
21
|
SlidersHorizontal,
|
|
21
22
|
ArrowRightLeft,
|
|
22
23
|
MoreHorizontal,
|
|
24
|
+
Download,
|
|
25
|
+
Link,
|
|
23
26
|
} from "lucide-react";
|
|
24
27
|
|
|
25
28
|
const ROOT_PARENT_VALUE = "__root__";
|
|
26
29
|
|
|
30
|
+
// ── Download helpers ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function downloadJson(data: unknown, filename: string) {
|
|
33
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
|
34
|
+
type: "application/json",
|
|
35
|
+
});
|
|
36
|
+
const url = URL.createObjectURL(blob);
|
|
37
|
+
const a = document.createElement("a");
|
|
38
|
+
a.href = url;
|
|
39
|
+
a.download = filename;
|
|
40
|
+
a.click();
|
|
41
|
+
URL.revokeObjectURL(url);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildQuestionExport(q: Question, allQuestions: Question[]): object {
|
|
45
|
+
const children = allQuestions
|
|
46
|
+
.filter((c) => c.parentQuestionId === q.id)
|
|
47
|
+
.map((c) => buildQuestionExport(c, allQuestions));
|
|
48
|
+
return {
|
|
49
|
+
id: q.id,
|
|
50
|
+
title: q.title,
|
|
51
|
+
topicId: q.topicId,
|
|
52
|
+
parentQuestionId: q.parentQuestionId ?? null,
|
|
53
|
+
systemContext: q.systemContext,
|
|
54
|
+
contextFiles: q.contextFiles,
|
|
55
|
+
codeContextFiles: q.codeContextFiles,
|
|
56
|
+
messages: q.messages,
|
|
57
|
+
annotations: q.annotations ?? [],
|
|
58
|
+
linkedConversationIds: q.linkedConversationIds ?? [],
|
|
59
|
+
createdAt: q.createdAt,
|
|
60
|
+
...(children.length > 0 ? { children } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
27
64
|
export default function Sidebar() {
|
|
28
65
|
const {
|
|
29
66
|
topics,
|
|
@@ -56,6 +93,9 @@ export default function Sidebar() {
|
|
|
56
93
|
uploadWorkspaceFiles,
|
|
57
94
|
removeWorkspaceFile,
|
|
58
95
|
updateTopicSystemContext,
|
|
96
|
+
currentQuestion,
|
|
97
|
+
linkConversation,
|
|
98
|
+
unlinkConversation,
|
|
59
99
|
} = useStore();
|
|
60
100
|
|
|
61
101
|
const [newTopicName, setNewTopicName] = useState("");
|
|
@@ -460,6 +500,53 @@ export default function Sidebar() {
|
|
|
460
500
|
<ArrowRightLeft className="w-3 h-3" /> Move
|
|
461
501
|
</button>
|
|
462
502
|
<div className="border-t border-slate-700 my-0.5" />
|
|
503
|
+
<button
|
|
504
|
+
onClick={() => {
|
|
505
|
+
setOpenMenuQuestionId(null);
|
|
506
|
+
const allQ = questionsByTopic[topicId] ?? [];
|
|
507
|
+
downloadJson(
|
|
508
|
+
buildQuestionExport(q, allQ),
|
|
509
|
+
`${q.title.replace(/[^a-z0-9]+/gi, "-")}.json`,
|
|
510
|
+
);
|
|
511
|
+
}}
|
|
512
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
|
513
|
+
>
|
|
514
|
+
<Download className="w-3 h-3" /> Download
|
|
515
|
+
</button>
|
|
516
|
+
{/* Link to current — only shown when a different question is open */}
|
|
517
|
+
{currentQuestion &&
|
|
518
|
+
currentQuestion.id !== q.id &&
|
|
519
|
+
(() => {
|
|
520
|
+
const alreadyLinked = (
|
|
521
|
+
currentQuestion.linkedConversationIds ?? []
|
|
522
|
+
).includes(q.id);
|
|
523
|
+
return (
|
|
524
|
+
<>
|
|
525
|
+
<div className="border-t border-slate-700 my-0.5" />
|
|
526
|
+
<button
|
|
527
|
+
onClick={() => {
|
|
528
|
+
setOpenMenuQuestionId(null);
|
|
529
|
+
if (alreadyLinked) {
|
|
530
|
+
unlinkConversation(currentQuestion.id, q.id);
|
|
531
|
+
} else {
|
|
532
|
+
linkConversation(currentQuestion.id, q.id);
|
|
533
|
+
}
|
|
534
|
+
}}
|
|
535
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-slate-700 transition-colors ${
|
|
536
|
+
alreadyLinked
|
|
537
|
+
? "text-violet-400 hover:text-violet-300"
|
|
538
|
+
: "text-slate-300 hover:text-white"
|
|
539
|
+
}`}
|
|
540
|
+
>
|
|
541
|
+
<Link className="w-3 h-3" />
|
|
542
|
+
{alreadyLinked
|
|
543
|
+
? "Unlink from current"
|
|
544
|
+
: "Link to current"}
|
|
545
|
+
</button>
|
|
546
|
+
</>
|
|
547
|
+
);
|
|
548
|
+
})()}
|
|
549
|
+
<div className="border-t border-slate-700 my-0.5" />
|
|
463
550
|
<button
|
|
464
551
|
onClick={() => {
|
|
465
552
|
setOpenMenuQuestionId(null);
|
|
@@ -789,6 +876,32 @@ export default function Sidebar() {
|
|
|
789
876
|
>
|
|
790
877
|
<Trash2 className="w-3 h-3" />
|
|
791
878
|
</button>
|
|
879
|
+
<button
|
|
880
|
+
onClick={(e) => {
|
|
881
|
+
e.stopPropagation();
|
|
882
|
+
const topicQuestions = questionsByTopic[topic.id] ?? [];
|
|
883
|
+
const rootQuestions = topicQuestions.filter(
|
|
884
|
+
(q) => !q.parentQuestionId,
|
|
885
|
+
);
|
|
886
|
+
downloadJson(
|
|
887
|
+
{
|
|
888
|
+
id: topic.id,
|
|
889
|
+
name: topic.name,
|
|
890
|
+
systemContext: topic.systemContext ?? "",
|
|
891
|
+
contextFiles: topic.contextFiles,
|
|
892
|
+
createdAt: topic.createdAt,
|
|
893
|
+
questions: rootQuestions.map((q) =>
|
|
894
|
+
buildQuestionExport(q, topicQuestions),
|
|
895
|
+
),
|
|
896
|
+
},
|
|
897
|
+
`${topic.name.replace(/[^a-z0-9]+/gi, "-")}.json`,
|
|
898
|
+
);
|
|
899
|
+
}}
|
|
900
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
901
|
+
title="Download topic as JSON"
|
|
902
|
+
>
|
|
903
|
+
<Download className="w-3 h-3" />
|
|
904
|
+
</button>
|
|
792
905
|
</div>
|
|
793
906
|
|
|
794
907
|
{/* Questions list */}
|
|
@@ -220,6 +220,7 @@ interface Store {
|
|
|
220
220
|
| "user"
|
|
221
221
|
| "ai"
|
|
222
222
|
| "sandbox"
|
|
223
|
+
| "browser-security"
|
|
223
224
|
| "infra"
|
|
224
225
|
| "react"
|
|
225
226
|
| "nextjs"
|
|
@@ -283,6 +284,8 @@ interface Store {
|
|
|
283
284
|
clientCode: string;
|
|
284
285
|
clientLang: string;
|
|
285
286
|
fileId?: string;
|
|
287
|
+
label?: string;
|
|
288
|
+
origin?: "sandbox" | "browser-security";
|
|
286
289
|
/** If set, the client panel opens in React or Next.js preview mode instead of script mode */
|
|
287
290
|
clientType?: "script" | "react" | "nextjs" | "module-federation";
|
|
288
291
|
reactFiles?: Record<string, string> | null;
|
|
@@ -298,6 +301,8 @@ interface Store {
|
|
|
298
301
|
clientLang: string,
|
|
299
302
|
fileId?: string,
|
|
300
303
|
opts?: {
|
|
304
|
+
label?: string;
|
|
305
|
+
origin?: "sandbox" | "browser-security";
|
|
301
306
|
clientType?: "script" | "react" | "nextjs" | "module-federation";
|
|
302
307
|
reactFiles?: Record<string, string>;
|
|
303
308
|
reactActiveFile?: string;
|
|
@@ -320,6 +325,11 @@ interface Store {
|
|
|
320
325
|
openDeploymentLab: () => void;
|
|
321
326
|
closeDeploymentLab: () => void;
|
|
322
327
|
|
|
328
|
+
// ── Browser Security Lab ─────────────────────────────────────
|
|
329
|
+
showBrowserSecurityLab: boolean;
|
|
330
|
+
openBrowserSecurityLab: () => void;
|
|
331
|
+
closeBrowserSecurityLab: () => void;
|
|
332
|
+
|
|
323
333
|
// ── Infra Lab ────────────────────────────────────────────────
|
|
324
334
|
showInfraLab: boolean;
|
|
325
335
|
runnerInitialInfra: InfraLabWorkspace | null;
|
|
@@ -373,6 +383,7 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
373
383
|
runnerInitialSandbox: null,
|
|
374
384
|
runnerInitialFileId: null,
|
|
375
385
|
showDeploymentLab: false,
|
|
386
|
+
showBrowserSecurityLab: false,
|
|
376
387
|
showInfraLab: false,
|
|
377
388
|
runnerInitialInfra: null,
|
|
378
389
|
runnerInitialInfraFileId: null,
|
|
@@ -971,6 +982,8 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
971
982
|
clientCode,
|
|
972
983
|
clientLang,
|
|
973
984
|
fileId,
|
|
985
|
+
label: opts?.label,
|
|
986
|
+
origin: opts?.origin,
|
|
974
987
|
clientType: opts?.clientType,
|
|
975
988
|
reactFiles: opts?.reactFiles,
|
|
976
989
|
reactActiveFile: opts?.reactActiveFile,
|
|
@@ -1058,9 +1071,10 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
1058
1071
|
}));
|
|
1059
1072
|
},
|
|
1060
1073
|
closeCodeRunner: () => set({ showCodeRunner: false }),
|
|
1061
|
-
showDeploymentLab: false,
|
|
1062
1074
|
openDeploymentLab: () => set({ showDeploymentLab: true }),
|
|
1063
1075
|
closeDeploymentLab: () => set({ showDeploymentLab: false }),
|
|
1076
|
+
openBrowserSecurityLab: () => set({ showBrowserSecurityLab: true }),
|
|
1077
|
+
closeBrowserSecurityLab: () => set({ showBrowserSecurityLab: false }),
|
|
1064
1078
|
closeInfraLab: () => set({ showInfraLab: false }),
|
|
1065
1079
|
|
|
1066
1080
|
fetchAiSettings: async () => {
|
|
@@ -3,6 +3,7 @@ export type ContextFileOrigin =
|
|
|
3
3
|
| "ai"
|
|
4
4
|
| "upload"
|
|
5
5
|
| "sandbox"
|
|
6
|
+
| "browser-security"
|
|
6
7
|
| "infra"
|
|
7
8
|
| "react"
|
|
8
9
|
| "nextjs"
|
|
@@ -17,6 +18,7 @@ export interface ContextFile {
|
|
|
17
18
|
/** Distinguishes how this file was added. 'upload' = user-uploaded doc,
|
|
18
19
|
* 'user' = code saved from Code Runner, 'ai' = AI-generated code block,
|
|
19
20
|
* 'sandbox' = paired server+client sandbox saved as JSON,
|
|
21
|
+
* 'browser-security' = paired server+client security lab saved as JSON,
|
|
20
22
|
* 'infra' = Terraform-style infra lab workspace saved as JSON. */
|
|
21
23
|
origin?: ContextFileOrigin;
|
|
22
24
|
/** Language hint for code snippets (e.g. 'typescript', 'javascript'). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/app.tsx","./src/api.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/infralabmodal.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"errors":true,"version":"5.9.3"}
|
|
1
|
+
{"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"errors":true,"version":"5.9.3"}
|
|
@@ -358,6 +358,7 @@ export async function syncWorkspace(
|
|
|
358
358
|
(cs.origin === "user" ||
|
|
359
359
|
cs.origin === "ai" ||
|
|
360
360
|
cs.origin === "sandbox" ||
|
|
361
|
+
cs.origin === "browser-security" ||
|
|
361
362
|
cs.origin === "react" ||
|
|
362
363
|
cs.origin === "nextjs" ||
|
|
363
364
|
cs.origin === "module-federation" ||
|
|
@@ -769,6 +770,7 @@ export async function exportWorkspace(
|
|
|
769
770
|
cf.origin === "user" ||
|
|
770
771
|
cf.origin === "ai" ||
|
|
771
772
|
cf.origin === "sandbox" ||
|
|
773
|
+
cf.origin === "browser-security" ||
|
|
772
774
|
cf.origin === "react" ||
|
|
773
775
|
cf.origin === "nextjs" ||
|
|
774
776
|
cf.origin === "module-federation" ||
|
|
@@ -726,6 +726,7 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
726
726
|
| "user"
|
|
727
727
|
| "ai"
|
|
728
728
|
| "sandbox"
|
|
729
|
+
| "browser-security"
|
|
729
730
|
| "infra"
|
|
730
731
|
| "react"
|
|
731
732
|
| "nextjs"
|
|
@@ -738,6 +739,7 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
738
739
|
origin !== "user" &&
|
|
739
740
|
origin !== "ai" &&
|
|
740
741
|
origin !== "sandbox" &&
|
|
742
|
+
origin !== "browser-security" &&
|
|
741
743
|
origin !== "infra" &&
|
|
742
744
|
origin !== "react" &&
|
|
743
745
|
origin !== "nextjs" &&
|
|
@@ -745,7 +747,7 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
745
747
|
) {
|
|
746
748
|
return res.status(400).json({
|
|
747
749
|
error:
|
|
748
|
-
"origin must be 'user', 'ai', 'sandbox', 'infra', 'react', 'nextjs', or 'module-federation'",
|
|
750
|
+
"origin must be 'user', 'ai', 'sandbox', 'browser-security', 'infra', 'react', 'nextjs', or 'module-federation'",
|
|
749
751
|
});
|
|
750
752
|
}
|
|
751
753
|
try {
|
|
@@ -2885,6 +2887,51 @@ app.post("/api/nextjs/:id/update-files", async (req, res) => {
|
|
|
2885
2887
|
res.json({ ok: true });
|
|
2886
2888
|
});
|
|
2887
2889
|
|
|
2890
|
+
app.post("/api/nextjs/:id/command-stream", async (req, res) => {
|
|
2891
|
+
const sb = nextSandboxes.get(req.params.id);
|
|
2892
|
+
|
|
2893
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
2894
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2895
|
+
res.setHeader("Connection", "keep-alive");
|
|
2896
|
+
res.flushHeaders();
|
|
2897
|
+
|
|
2898
|
+
const send = (payload: unknown) => {
|
|
2899
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
2900
|
+
};
|
|
2901
|
+
|
|
2902
|
+
if (!sb) {
|
|
2903
|
+
send({ type: "error", error: "Sandbox not found" });
|
|
2904
|
+
res.end();
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
const { command } = req.body as { command?: string };
|
|
2909
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
2910
|
+
send({ type: "error", error: "command is required" });
|
|
2911
|
+
res.end();
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
try {
|
|
2916
|
+
const parsed = parseReactLabCommand(command);
|
|
2917
|
+
send({ type: "output", kind: "info", text: `$ ${command.trim()}\n` });
|
|
2918
|
+
await runStreamedCommand(
|
|
2919
|
+
npmCommand(),
|
|
2920
|
+
parsed.args,
|
|
2921
|
+
{
|
|
2922
|
+
cwd: sb.dir,
|
|
2923
|
+
env: { ...process.env, npm_config_update_notifier: "false" },
|
|
2924
|
+
},
|
|
2925
|
+
({ kind, text }) => send({ type: "output", kind, text }),
|
|
2926
|
+
);
|
|
2927
|
+
send({ type: "complete" });
|
|
2928
|
+
} catch (error: any) {
|
|
2929
|
+
send({ type: "error", error: error?.message || "Command failed" });
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
res.end();
|
|
2933
|
+
});
|
|
2934
|
+
|
|
2888
2935
|
app.get("/api/nextjs/:id/status", (req, res) => {
|
|
2889
2936
|
const sb = nextSandboxes.get(req.params.id);
|
|
2890
2937
|
if (!sb) return res.json({ running: false });
|
|
@@ -59,6 +59,7 @@ export interface ContextFile {
|
|
|
59
59
|
/** Distinguishes how this file was added. 'upload' = user-uploaded doc,
|
|
60
60
|
* 'user' = code saved from Code Runner, 'ai' = AI-generated code block,
|
|
61
61
|
* 'sandbox' = paired server+client sandbox saved as JSON,
|
|
62
|
+
* 'browser-security' = paired server+client security lab saved as JSON,
|
|
62
63
|
* 'infra' = Terraform-style infra lab workspace saved as JSON,
|
|
63
64
|
* 'react' = React + TypeScript lab workspace,
|
|
64
65
|
* 'nextjs' = Next.js App Router lab workspace,
|
|
@@ -68,6 +69,7 @@ export interface ContextFile {
|
|
|
68
69
|
| "ai"
|
|
69
70
|
| "upload"
|
|
70
71
|
| "sandbox"
|
|
72
|
+
| "browser-security"
|
|
71
73
|
| "infra"
|
|
72
74
|
| "react"
|
|
73
75
|
| "nextjs"
|