create-interview-cockpit 0.14.0 → 0.16.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/App.tsx +3 -0
- 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/CanvasLabModal.tsx +585 -0
- package/template/client/src/components/CodeRunnerModal.tsx +406 -55
- package/template/client/src/components/LabsPanel.tsx +120 -7
- package/template/client/src/components/LinkedConvosPicker.tsx +121 -63
- package/template/client/src/components/Sidebar.tsx +125 -1
- package/template/client/src/components/WorkspaceSwitcher.tsx +36 -0
- package/template/client/src/reactLab.ts +1206 -0
- package/template/client/src/store.ts +39 -2
- package/template/client/src/types.ts +5 -1
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/client/vite.config.ts +15 -8
- package/template/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +6 -1
- package/template/server/src/index.ts +110 -6
- package/template/server/src/storage.ts +3 -0
|
@@ -4,7 +4,13 @@ import { parseInfraLabWorkspace } from "../infraLab";
|
|
|
4
4
|
import {
|
|
5
5
|
parseFrontendLabWorkspace,
|
|
6
6
|
ISOLATED_MODULE_FEDERATION_LAB,
|
|
7
|
+
NEXTJS_MF_PLUGIN_LAB,
|
|
8
|
+
NEXTJS_MF_RUNTIME_LAB,
|
|
9
|
+
NEXTJS_MULTI_ZONES_LAB,
|
|
10
|
+
NEXTJS_MF_RUNTIME_API_LAB,
|
|
11
|
+
RSPACK_SHELL_LAB,
|
|
7
12
|
} from "../reactLab";
|
|
13
|
+
import { BROWSER_SECURITY_TEMPLATES } from "../browserSecurityTemplates";
|
|
8
14
|
import type { ContextFile } from "../types";
|
|
9
15
|
import {
|
|
10
16
|
Plus,
|
|
@@ -21,16 +27,20 @@ import {
|
|
|
21
27
|
LinkIcon,
|
|
22
28
|
Link2Off,
|
|
23
29
|
Network,
|
|
30
|
+
Shield,
|
|
31
|
+
PenLine,
|
|
24
32
|
} from "lucide-react";
|
|
25
33
|
|
|
26
34
|
// ─── Helpers ─────────────────────────────────────────────
|
|
27
35
|
|
|
28
36
|
const LAB_ORIGINS = new Set([
|
|
29
37
|
"sandbox",
|
|
38
|
+
"browser-security",
|
|
30
39
|
"infra",
|
|
31
40
|
"react",
|
|
32
41
|
"nextjs",
|
|
33
42
|
"module-federation",
|
|
43
|
+
"canvas",
|
|
34
44
|
]);
|
|
35
45
|
|
|
36
46
|
function isLabFile(cf: ContextFile) {
|
|
@@ -190,6 +200,7 @@ export default function LabsPanel() {
|
|
|
190
200
|
openNextLab,
|
|
191
201
|
openModuleFederationLab,
|
|
192
202
|
openDeploymentLab,
|
|
203
|
+
openCanvasLab,
|
|
193
204
|
removeQuestionFile,
|
|
194
205
|
detachLabFile,
|
|
195
206
|
attachLabFile,
|
|
@@ -239,19 +250,50 @@ export default function LabsPanel() {
|
|
|
239
250
|
parsed.clientCode,
|
|
240
251
|
parsed.clientLang,
|
|
241
252
|
cf.id,
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
{
|
|
254
|
+
label: cf.label || cf.originalName,
|
|
255
|
+
origin:
|
|
256
|
+
cf.origin === "browser-security" ? "browser-security" : "sandbox",
|
|
257
|
+
...(parsed.clientType
|
|
258
|
+
? {
|
|
259
|
+
clientType: parsed.clientType,
|
|
260
|
+
reactFiles: parsed.reactFiles,
|
|
261
|
+
reactActiveFile: parsed.reactActiveFile,
|
|
262
|
+
}
|
|
263
|
+
: {}),
|
|
264
|
+
},
|
|
249
265
|
);
|
|
250
266
|
} catch {
|
|
251
267
|
/* ignore */
|
|
252
268
|
}
|
|
253
269
|
};
|
|
254
270
|
|
|
271
|
+
const openBrowserSecurityTemplate = (templateId: string) => {
|
|
272
|
+
const template = BROWSER_SECURITY_TEMPLATES.find(
|
|
273
|
+
(item) => item.id === templateId,
|
|
274
|
+
);
|
|
275
|
+
if (!template) return;
|
|
276
|
+
|
|
277
|
+
openSandbox(
|
|
278
|
+
template.serverCode,
|
|
279
|
+
"javascript",
|
|
280
|
+
template.clientCode,
|
|
281
|
+
"javascript",
|
|
282
|
+
undefined,
|
|
283
|
+
{
|
|
284
|
+
label: template.label,
|
|
285
|
+
origin: "browser-security",
|
|
286
|
+
...(template.clientType
|
|
287
|
+
? {
|
|
288
|
+
clientType: template.clientType,
|
|
289
|
+
reactFiles: template.reactFiles,
|
|
290
|
+
reactActiveFile: template.reactActiveFile,
|
|
291
|
+
}
|
|
292
|
+
: {}),
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
};
|
|
296
|
+
|
|
255
297
|
const openInfraFile = async (cf: ContextFile) => {
|
|
256
298
|
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
257
299
|
.then((r) => r.json())
|
|
@@ -372,6 +414,17 @@ export default function LabsPanel() {
|
|
|
372
414
|
}
|
|
373
415
|
};
|
|
374
416
|
|
|
417
|
+
const openCanvasFile = async (cf: ContextFile) => {
|
|
418
|
+
try {
|
|
419
|
+
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
420
|
+
.then((r) => r.json())
|
|
421
|
+
.then((d) => d.content as string);
|
|
422
|
+
openCanvasLab(raw, cf.id);
|
|
423
|
+
} catch {
|
|
424
|
+
/* ignore */
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
375
428
|
// ── Section renderer ─────────────────────────────────────
|
|
376
429
|
|
|
377
430
|
function Section({
|
|
@@ -531,6 +584,22 @@ export default function LabsPanel() {
|
|
|
531
584
|
accentClass="text-slate-300"
|
|
532
585
|
bgClass="bg-slate-500/10 border border-slate-500/20"
|
|
533
586
|
/>
|
|
587
|
+
<Section
|
|
588
|
+
title="Browser Security"
|
|
589
|
+
icon={Shield}
|
|
590
|
+
iconColor="text-cyan-400/70"
|
|
591
|
+
origin="browser-security"
|
|
592
|
+
emptyText="Save a browser security lab to reopen it here"
|
|
593
|
+
newLabMenu={BROWSER_SECURITY_TEMPLATES.map((template) => ({
|
|
594
|
+
label: template.label,
|
|
595
|
+
description: template.description,
|
|
596
|
+
onClick: () => openBrowserSecurityTemplate(template.id),
|
|
597
|
+
}))}
|
|
598
|
+
onOpen={openSandboxFile}
|
|
599
|
+
openTitle="Open in Browser Security Lab"
|
|
600
|
+
accentClass="text-cyan-200"
|
|
601
|
+
bgClass="bg-cyan-500/10 border border-cyan-500/20"
|
|
602
|
+
/>
|
|
534
603
|
<Section
|
|
535
604
|
title="Infra Labs"
|
|
536
605
|
icon={Globe}
|
|
@@ -590,12 +659,56 @@ export default function LabsPanel() {
|
|
|
590
659
|
onClick: () =>
|
|
591
660
|
openModuleFederationLab(ISOLATED_MODULE_FEDERATION_LAB),
|
|
592
661
|
},
|
|
662
|
+
{
|
|
663
|
+
label: "Next.js MF — Plugin (Option A)",
|
|
664
|
+
description:
|
|
665
|
+
"Next.js shell + remote via built-in webpack.container.ModuleFederationPlugin",
|
|
666
|
+
onClick: () => openModuleFederationLab(NEXTJS_MF_PLUGIN_LAB),
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
label: "Next.js MF — Runtime Loader (Option B)",
|
|
670
|
+
description:
|
|
671
|
+
"Next.js shell with no plugin — loads remote via plain script injection",
|
|
672
|
+
onClick: () => openModuleFederationLab(NEXTJS_MF_RUNTIME_LAB),
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
label: "Next.js — Multi-Zones",
|
|
676
|
+
description:
|
|
677
|
+
"Two independent Next.js apps split by URL path via rewrites — Vercel recommended",
|
|
678
|
+
onClick: () => openModuleFederationLab(NEXTJS_MULTI_ZONES_LAB),
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
label: "Next.js — MF Runtime API",
|
|
682
|
+
description:
|
|
683
|
+
"@module-federation/enhanced/runtime inside a 'use client' component — no webpack config",
|
|
684
|
+
onClick: () =>
|
|
685
|
+
openModuleFederationLab(NEXTJS_MF_RUNTIME_API_LAB),
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
label: "Rspack Shell — Native MF 2.0",
|
|
689
|
+
description:
|
|
690
|
+
"Rspack as the host with built-in MF support; webpack app as the remote",
|
|
691
|
+
onClick: () => openModuleFederationLab(RSPACK_SHELL_LAB),
|
|
692
|
+
},
|
|
593
693
|
]}
|
|
594
694
|
onOpen={openMFFile}
|
|
595
695
|
openTitle="Open in Webpack Module Federation Lab"
|
|
596
696
|
accentClass="text-emerald-200"
|
|
597
697
|
bgClass="bg-emerald-500/10 border border-emerald-500/20"
|
|
598
698
|
/>
|
|
699
|
+
<Section
|
|
700
|
+
title="Canvas Labs"
|
|
701
|
+
icon={PenLine}
|
|
702
|
+
iconColor="text-orange-400/70"
|
|
703
|
+
origin="canvas"
|
|
704
|
+
emptyText="Save a canvas lab to reopen it here"
|
|
705
|
+
onNewLab={() => openCanvasLab()}
|
|
706
|
+
newLabTitle="Open Canvas Lab"
|
|
707
|
+
onOpen={openCanvasFile}
|
|
708
|
+
openTitle="Open in Canvas Lab"
|
|
709
|
+
accentClass="text-orange-200"
|
|
710
|
+
bgClass="bg-orange-500/10 border border-orange-500/20"
|
|
711
|
+
/>
|
|
599
712
|
</div>
|
|
600
713
|
) : (
|
|
601
714
|
<div className="flex-1 flex items-center justify-center">
|
|
@@ -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("");
|
|
@@ -114,6 +154,9 @@ export default function Sidebar() {
|
|
|
114
154
|
|
|
115
155
|
// Drive subfolder navigator
|
|
116
156
|
const activeWs = workspaces.find((w) => w.id === activeWorkspaceId);
|
|
157
|
+
const activeWsSortOrder = (activeWs?.questionSortOrder ?? "name") as
|
|
158
|
+
| "name"
|
|
159
|
+
| "createdAt";
|
|
117
160
|
const isDriveWs =
|
|
118
161
|
activeWs?.type === "google_drive" && !!activeWs.driveConfig?.folderId;
|
|
119
162
|
const currentSubFolder = activeWs?.driveConfig?.subFolderId
|
|
@@ -460,6 +503,53 @@ export default function Sidebar() {
|
|
|
460
503
|
<ArrowRightLeft className="w-3 h-3" /> Move
|
|
461
504
|
</button>
|
|
462
505
|
<div className="border-t border-slate-700 my-0.5" />
|
|
506
|
+
<button
|
|
507
|
+
onClick={() => {
|
|
508
|
+
setOpenMenuQuestionId(null);
|
|
509
|
+
const allQ = questionsByTopic[topicId] ?? [];
|
|
510
|
+
downloadJson(
|
|
511
|
+
buildQuestionExport(q, allQ),
|
|
512
|
+
`${q.title.replace(/[^a-z0-9]+/gi, "-")}.json`,
|
|
513
|
+
);
|
|
514
|
+
}}
|
|
515
|
+
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"
|
|
516
|
+
>
|
|
517
|
+
<Download className="w-3 h-3" /> Download
|
|
518
|
+
</button>
|
|
519
|
+
{/* Link to current — only shown when a different question is open */}
|
|
520
|
+
{currentQuestion &&
|
|
521
|
+
currentQuestion.id !== q.id &&
|
|
522
|
+
(() => {
|
|
523
|
+
const alreadyLinked = (
|
|
524
|
+
currentQuestion.linkedConversationIds ?? []
|
|
525
|
+
).includes(q.id);
|
|
526
|
+
return (
|
|
527
|
+
<>
|
|
528
|
+
<div className="border-t border-slate-700 my-0.5" />
|
|
529
|
+
<button
|
|
530
|
+
onClick={() => {
|
|
531
|
+
setOpenMenuQuestionId(null);
|
|
532
|
+
if (alreadyLinked) {
|
|
533
|
+
unlinkConversation(currentQuestion.id, q.id);
|
|
534
|
+
} else {
|
|
535
|
+
linkConversation(currentQuestion.id, q.id);
|
|
536
|
+
}
|
|
537
|
+
}}
|
|
538
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-slate-700 transition-colors ${
|
|
539
|
+
alreadyLinked
|
|
540
|
+
? "text-violet-400 hover:text-violet-300"
|
|
541
|
+
: "text-slate-300 hover:text-white"
|
|
542
|
+
}`}
|
|
543
|
+
>
|
|
544
|
+
<Link className="w-3 h-3" />
|
|
545
|
+
{alreadyLinked
|
|
546
|
+
? "Unlink from current"
|
|
547
|
+
: "Link to current"}
|
|
548
|
+
</button>
|
|
549
|
+
</>
|
|
550
|
+
);
|
|
551
|
+
})()}
|
|
552
|
+
<div className="border-t border-slate-700 my-0.5" />
|
|
463
553
|
<button
|
|
464
554
|
onClick={() => {
|
|
465
555
|
setOpenMenuQuestionId(null);
|
|
@@ -717,7 +807,15 @@ export default function Sidebar() {
|
|
|
717
807
|
)
|
|
718
808
|
.map((topic) => {
|
|
719
809
|
const isExpanded = expandedTopics.includes(topic.id);
|
|
720
|
-
const questions = questionsByTopic[topic.id] || []
|
|
810
|
+
const questions = [...(questionsByTopic[topic.id] || [])].sort(
|
|
811
|
+
(a, b) =>
|
|
812
|
+
activeWsSortOrder === "createdAt"
|
|
813
|
+
? a.createdAt.localeCompare(b.createdAt)
|
|
814
|
+
: a.title.localeCompare(b.title, undefined, {
|
|
815
|
+
numeric: true,
|
|
816
|
+
sensitivity: "base",
|
|
817
|
+
}),
|
|
818
|
+
);
|
|
721
819
|
|
|
722
820
|
return (
|
|
723
821
|
<div key={topic.id}>
|
|
@@ -789,6 +887,32 @@ export default function Sidebar() {
|
|
|
789
887
|
>
|
|
790
888
|
<Trash2 className="w-3 h-3" />
|
|
791
889
|
</button>
|
|
890
|
+
<button
|
|
891
|
+
onClick={(e) => {
|
|
892
|
+
e.stopPropagation();
|
|
893
|
+
const topicQuestions = questionsByTopic[topic.id] ?? [];
|
|
894
|
+
const rootQuestions = topicQuestions.filter(
|
|
895
|
+
(q) => !q.parentQuestionId,
|
|
896
|
+
);
|
|
897
|
+
downloadJson(
|
|
898
|
+
{
|
|
899
|
+
id: topic.id,
|
|
900
|
+
name: topic.name,
|
|
901
|
+
systemContext: topic.systemContext ?? "",
|
|
902
|
+
contextFiles: topic.contextFiles,
|
|
903
|
+
createdAt: topic.createdAt,
|
|
904
|
+
questions: rootQuestions.map((q) =>
|
|
905
|
+
buildQuestionExport(q, topicQuestions),
|
|
906
|
+
),
|
|
907
|
+
},
|
|
908
|
+
`${topic.name.replace(/[^a-z0-9]+/gi, "-")}.json`,
|
|
909
|
+
);
|
|
910
|
+
}}
|
|
911
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
912
|
+
title="Download topic as JSON"
|
|
913
|
+
>
|
|
914
|
+
<Download className="w-3 h-3" />
|
|
915
|
+
</button>
|
|
792
916
|
</div>
|
|
793
917
|
|
|
794
918
|
{/* Questions list */}
|
|
@@ -27,6 +27,7 @@ export default function WorkspaceSwitcher() {
|
|
|
27
27
|
createWorkspace,
|
|
28
28
|
deleteWorkspace,
|
|
29
29
|
renameWorkspace,
|
|
30
|
+
patchWorkspace,
|
|
30
31
|
syncWorkspace,
|
|
31
32
|
linkDriveFolder,
|
|
32
33
|
attachDriveFolder,
|
|
@@ -814,6 +815,41 @@ export default function WorkspaceSwitcher() {
|
|
|
814
815
|
)}
|
|
815
816
|
</div>
|
|
816
817
|
)}
|
|
818
|
+
|
|
819
|
+
{/* Question sort order */}
|
|
820
|
+
<div
|
|
821
|
+
className="mt-1 ml-5 flex items-center gap-1"
|
|
822
|
+
onClick={(e) => e.stopPropagation()}
|
|
823
|
+
>
|
|
824
|
+
<span className="text-[10px] text-slate-600">Order:</span>
|
|
825
|
+
<button
|
|
826
|
+
onClick={() =>
|
|
827
|
+
patchWorkspace(ws.id, { questionSortOrder: "name" })
|
|
828
|
+
}
|
|
829
|
+
className={`text-[10px] px-1 rounded transition-colors ${
|
|
830
|
+
(ws.questionSortOrder ?? "name") === "name"
|
|
831
|
+
? "text-cyan-400"
|
|
832
|
+
: "text-slate-600 hover:text-slate-400"
|
|
833
|
+
}`}
|
|
834
|
+
>
|
|
835
|
+
Name
|
|
836
|
+
</button>
|
|
837
|
+
<span className="text-[10px] text-slate-700">·</span>
|
|
838
|
+
<button
|
|
839
|
+
onClick={() =>
|
|
840
|
+
patchWorkspace(ws.id, {
|
|
841
|
+
questionSortOrder: "createdAt",
|
|
842
|
+
})
|
|
843
|
+
}
|
|
844
|
+
className={`text-[10px] px-1 rounded transition-colors ${
|
|
845
|
+
ws.questionSortOrder === "createdAt"
|
|
846
|
+
? "text-cyan-400"
|
|
847
|
+
: "text-slate-600 hover:text-slate-400"
|
|
848
|
+
}`}
|
|
849
|
+
>
|
|
850
|
+
Date created
|
|
851
|
+
</button>
|
|
852
|
+
</div>
|
|
817
853
|
</div>
|
|
818
854
|
))}
|
|
819
855
|
</div>
|