create-interview-cockpit 0.13.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.
@@ -1,7 +1,11 @@
1
1
  import { useState } from "react";
2
2
  import { useStore } from "../store";
3
3
  import { parseInfraLabWorkspace } from "../infraLab";
4
- import { parseFrontendLabWorkspace } from "../reactLab";
4
+ import {
5
+ parseFrontendLabWorkspace,
6
+ ISOLATED_MODULE_FEDERATION_LAB,
7
+ } from "../reactLab";
8
+ import { BROWSER_SECURITY_TEMPLATES } from "../browserSecurityTemplates";
5
9
  import type { ContextFile } from "../types";
6
10
  import {
7
11
  Plus,
@@ -18,12 +22,14 @@ import {
18
22
  LinkIcon,
19
23
  Link2Off,
20
24
  Network,
25
+ Shield,
21
26
  } from "lucide-react";
22
27
 
23
28
  // ─── Helpers ─────────────────────────────────────────────
24
29
 
25
30
  const LAB_ORIGINS = new Set([
26
31
  "sandbox",
32
+ "browser-security",
27
33
  "infra",
28
34
  "react",
29
35
  "nextjs",
@@ -236,19 +242,50 @@ export default function LabsPanel() {
236
242
  parsed.clientCode,
237
243
  parsed.clientLang,
238
244
  cf.id,
239
- parsed.clientType
240
- ? {
241
- clientType: parsed.clientType,
242
- reactFiles: parsed.reactFiles,
243
- reactActiveFile: parsed.reactActiveFile,
244
- }
245
- : 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
+ },
246
257
  );
247
258
  } catch {
248
259
  /* ignore */
249
260
  }
250
261
  };
251
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
+
252
289
  const openInfraFile = async (cf: ContextFile) => {
253
290
  const raw = await fetch(`/api/context-files/${cf.id}/content`)
254
291
  .then((r) => r.json())
@@ -379,6 +416,7 @@ export default function LabsPanel() {
379
416
  emptyText,
380
417
  onNewLab,
381
418
  newLabTitle,
419
+ newLabMenu,
382
420
  onOpen,
383
421
  openTitle,
384
422
  accentClass,
@@ -391,11 +429,17 @@ export default function LabsPanel() {
391
429
  emptyText: string;
392
430
  onNewLab?: () => void;
393
431
  newLabTitle?: string;
432
+ newLabMenu?: Array<{
433
+ label: string;
434
+ description: string;
435
+ onClick: () => void;
436
+ }>;
394
437
  onOpen: (cf: ContextFile) => void;
395
438
  openTitle: string;
396
439
  accentClass: string;
397
440
  bgClass: string;
398
441
  }) {
442
+ const [menuOpen, setMenuOpen] = useState(false);
399
443
  const items = byOrigin(origin);
400
444
  if (!currentQuestion) return null;
401
445
 
@@ -408,7 +452,45 @@ export default function LabsPanel() {
408
452
  {title} ({items.length})
409
453
  </span>
410
454
  </div>
411
- {onNewLab && (
455
+ {newLabMenu ? (
456
+ <div className="relative">
457
+ <button
458
+ onClick={() => setMenuOpen((o) => !o)}
459
+ className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
460
+ title="New lab"
461
+ >
462
+ <Plus className="w-3.5 h-3.5" />
463
+ </button>
464
+ {menuOpen && (
465
+ <>
466
+ {/* backdrop to close on outside click */}
467
+ <div
468
+ className="fixed inset-0 z-40"
469
+ onClick={() => setMenuOpen(false)}
470
+ />
471
+ <div className="absolute right-0 top-full mt-1 z-50 bg-slate-800 border border-slate-600 rounded-lg shadow-xl w-52 overflow-hidden">
472
+ {newLabMenu.map((item) => (
473
+ <button
474
+ key={item.label}
475
+ onClick={() => {
476
+ item.onClick();
477
+ setMenuOpen(false);
478
+ }}
479
+ className="w-full text-left px-3 py-2 hover:bg-slate-700 transition-colors group"
480
+ >
481
+ <div className="text-[11px] font-medium text-slate-200 group-hover:text-cyan-300">
482
+ {item.label}
483
+ </div>
484
+ <div className="text-[10px] text-slate-500 mt-0.5 leading-snug">
485
+ {item.description}
486
+ </div>
487
+ </button>
488
+ ))}
489
+ </div>
490
+ </>
491
+ )}
492
+ </div>
493
+ ) : onNewLab ? (
412
494
  <button
413
495
  onClick={onNewLab}
414
496
  className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
@@ -416,7 +498,7 @@ export default function LabsPanel() {
416
498
  >
417
499
  <Plus className="w-3.5 h-3.5" />
418
500
  </button>
419
- )}
501
+ ) : null}
420
502
  </div>
421
503
  <div className="space-y-0.5 max-h-40 overflow-y-auto">
422
504
  {items.map((cf) => (
@@ -483,6 +565,22 @@ export default function LabsPanel() {
483
565
  accentClass="text-slate-300"
484
566
  bgClass="bg-slate-500/10 border border-slate-500/20"
485
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
+ />
486
584
  <Section
487
585
  title="Infra Labs"
488
586
  icon={Globe}
@@ -528,8 +626,21 @@ export default function LabsPanel() {
528
626
  iconColor="text-emerald-400/70"
529
627
  origin="module-federation"
530
628
  emptyText="Save a webpack module federation lab to reopen it here"
531
- onNewLab={() => openModuleFederationLab()}
532
- newLabTitle="Open Webpack Module Federation Lab"
629
+ newLabMenu={[
630
+ {
631
+ label: "Shared React Tree",
632
+ description:
633
+ "Shell & remote share one React runtime — classic federation",
634
+ onClick: () => openModuleFederationLab(),
635
+ },
636
+ {
637
+ label: "Isolated Mount / Unmount",
638
+ description:
639
+ "Remote owns its own React root via mount(el) / unmount(el)",
640
+ onClick: () =>
641
+ openModuleFederationLab(ISOLATED_MODULE_FEDERATION_LAB),
642
+ },
643
+ ]}
533
644
  onOpen={openMFFile}
534
645
  openTitle="Open in Webpack Module Federation Lab"
535
646
  accentClass="text-emerald-200"
@@ -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 */}