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.
- 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 +123 -12
- package/template/client/src/components/LinkedConvosPicker.tsx +121 -63
- package/template/client/src/components/Sidebar.tsx +113 -0
- package/template/client/src/reactLab.ts +408 -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/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +2 -0
- package/template/server/src/index.ts +90 -24
- package/template/server/src/storage.ts +2 -0
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useStore } from "../store";
|
|
3
3
|
import { parseInfraLabWorkspace } from "../infraLab";
|
|
4
|
-
import {
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
532
|
-
|
|
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 {
|
|
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 */}
|