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.
@@ -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
- parsed.clientType
243
- ? {
244
- clientType: parsed.clientType,
245
- reactFiles: parsed.reactFiles,
246
- reactActiveFile: parsed.reactActiveFile,
247
- }
248
- : undefined,
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 { useEffect, useState } from "react";
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 } = useStore();
18
- const [siblings, setSiblings] = useState<Question[]>([]);
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 className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
49
- <div className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[70vh]">
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-1">
66
- {loading && (
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
- No other conversations in this topic yet.
106
+ {search
107
+ ? "No conversations match your search."
108
+ : "No other conversations yet."}
74
109
  </p>
75
110
  )}
76
- {!loading &&
77
- siblings.map((sibling) => {
78
- const isLinked = linked.has(sibling.id);
79
- const isPending = pending === sibling.id;
80
- const msgCount = sibling.messages?.length ?? 0;
81
- return (
82
- <button
83
- key={sibling.id}
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
- <div
93
- className={`w-4 h-4 rounded border shrink-0 flex items-center justify-center transition-colors ${
94
- isLinked
95
- ? "bg-violet-500 border-violet-500"
96
- : "border-slate-600"
97
- }`}
98
- >
99
- {isPending ? (
100
- <Loader2 className="w-2.5 h-2.5 animate-spin text-white" />
101
- ) : isLinked ? (
102
- <Check className="w-2.5 h-2.5 text-white" />
103
- ) : null}
104
- </div>
105
- <div className="flex-1 min-w-0">
106
- <p className="text-xs font-medium text-slate-200 truncate">
107
- {sibling.title}
108
- </p>
109
- <p className="text-[10px] text-slate-500 mt-0.5">
110
- {msgCount} message{msgCount !== 1 ? "s" : ""}
111
- </p>
112
- </div>
113
- </button>
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"