create-interview-cockpit 0.17.3 → 0.19.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 +184 -8
- package/template/client/src/components/GhaHistoryPanel.tsx +194 -0
- package/template/client/src/components/GhaJobsPanel.tsx +432 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +1048 -0
- package/template/client/src/components/InfraLabModal.tsx +993 -262
- package/template/client/src/components/LabsPanel.tsx +71 -5
- package/template/client/src/components/Sidebar.tsx +603 -60
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +294 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +130 -10
- package/template/client/src/types.ts +33 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +793 -0
- package/template/server/src/google-drive.ts +542 -149
- package/template/server/src/index.ts +327 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, Fragment } from "react";
|
|
2
2
|
import { useStore } from "../store";
|
|
3
3
|
import type { Question } from "../types";
|
|
4
|
+
import * as api from "../api";
|
|
4
5
|
import FileAttachments from "./FileAttachments";
|
|
5
6
|
import WorkspaceSwitcher from "./WorkspaceSwitcher";
|
|
6
7
|
|
|
@@ -23,6 +24,8 @@ import {
|
|
|
23
24
|
MoreHorizontal,
|
|
24
25
|
Download,
|
|
25
26
|
Link,
|
|
27
|
+
Upload,
|
|
28
|
+
Copy,
|
|
26
29
|
} from "lucide-react";
|
|
27
30
|
|
|
28
31
|
const ROOT_PARENT_VALUE = "__root__";
|
|
@@ -41,20 +44,46 @@ function downloadJson(data: unknown, filename: string) {
|
|
|
41
44
|
URL.revokeObjectURL(url);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
47
|
+
async function serializeQuestionContextFiles(q: Question) {
|
|
48
|
+
return Promise.all(
|
|
49
|
+
(q.contextFiles || []).map(async (cf) => {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`/api/context-files/${cf.id}/content`, {
|
|
52
|
+
cache: "no-store",
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) return cf;
|
|
55
|
+
const body = (await res.json()) as { content?: string };
|
|
56
|
+
return typeof body.content === "string"
|
|
57
|
+
? { ...cf, content: body.content }
|
|
58
|
+
: cf;
|
|
59
|
+
} catch {
|
|
60
|
+
return cf;
|
|
61
|
+
}
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function buildQuestionExport(
|
|
67
|
+
q: Question,
|
|
68
|
+
allQuestions: Question[],
|
|
69
|
+
): Promise<object> {
|
|
70
|
+
const children = await Promise.all(
|
|
71
|
+
allQuestions
|
|
72
|
+
.filter((c) => c.parentQuestionId === q.id)
|
|
73
|
+
.map((c) => buildQuestionExport(c, allQuestions)),
|
|
74
|
+
);
|
|
48
75
|
return {
|
|
49
76
|
id: q.id,
|
|
50
77
|
title: q.title,
|
|
51
78
|
topicId: q.topicId,
|
|
52
79
|
parentQuestionId: q.parentQuestionId ?? null,
|
|
53
80
|
systemContext: q.systemContext,
|
|
54
|
-
contextFiles: q
|
|
81
|
+
contextFiles: await serializeQuestionContextFiles(q),
|
|
55
82
|
codeContextFiles: q.codeContextFiles,
|
|
56
83
|
messages: q.messages,
|
|
57
84
|
annotations: q.annotations ?? [],
|
|
85
|
+
readingBookmark: q.readingBookmark,
|
|
86
|
+
codeAnnotations: q.codeAnnotations ?? {},
|
|
58
87
|
linkedConversationIds: q.linkedConversationIds ?? [],
|
|
59
88
|
createdAt: q.createdAt,
|
|
60
89
|
...(children.length > 0 ? { children } : {}),
|
|
@@ -74,6 +103,7 @@ export default function Sidebar() {
|
|
|
74
103
|
addQuestion,
|
|
75
104
|
addChildQuestion,
|
|
76
105
|
moveQuestion,
|
|
106
|
+
copyQuestion,
|
|
77
107
|
removeQuestion,
|
|
78
108
|
renameQuestion,
|
|
79
109
|
selectQuestion,
|
|
@@ -89,6 +119,9 @@ export default function Sidebar() {
|
|
|
89
119
|
selectDriveSubfolder,
|
|
90
120
|
clearDriveSubfolder,
|
|
91
121
|
syncWorkspace,
|
|
122
|
+
syncTopic,
|
|
123
|
+
exportWorkspace,
|
|
124
|
+
exportTopic,
|
|
92
125
|
workspaceFiles,
|
|
93
126
|
uploadWorkspaceFiles,
|
|
94
127
|
removeWorkspaceFile,
|
|
@@ -113,12 +146,25 @@ export default function Sidebar() {
|
|
|
113
146
|
const [movingQuestionId, setMovingQuestionId] = useState<string | null>(null);
|
|
114
147
|
const [moveTargetParentId, setMoveTargetParentId] =
|
|
115
148
|
useState(ROOT_PARENT_VALUE);
|
|
149
|
+
const [copyingQuestionId, setCopyingQuestionId] = useState<string | null>(
|
|
150
|
+
null,
|
|
151
|
+
);
|
|
152
|
+
const [copyTargetParentId, setCopyTargetParentId] =
|
|
153
|
+
useState(ROOT_PARENT_VALUE);
|
|
154
|
+
const [copyingToTopicQuestionId, setCopyingToTopicQuestionId] = useState<
|
|
155
|
+
string | null
|
|
156
|
+
>(null);
|
|
157
|
+
const [copyTargetTopicId, setCopyTargetTopicId] = useState("");
|
|
158
|
+
const [copyTargetTopicParentId, setCopyTargetTopicParentId] =
|
|
159
|
+
useState(ROOT_PARENT_VALUE);
|
|
160
|
+
const [copyingNowId, setCopyingNowId] = useState<string | null>(null);
|
|
116
161
|
const [collapsedQuestions, setCollapsedQuestions] = useState<Set<string>>(
|
|
117
162
|
new Set(),
|
|
118
163
|
);
|
|
119
164
|
const [openMenuQuestionId, setOpenMenuQuestionId] = useState<string | null>(
|
|
120
165
|
null,
|
|
121
166
|
);
|
|
167
|
+
const [openMenuTopicId, setOpenMenuTopicId] = useState<string | null>(null);
|
|
122
168
|
const [openTopicPrompts, setOpenTopicPrompts] = useState<Set<string>>(
|
|
123
169
|
new Set(),
|
|
124
170
|
);
|
|
@@ -165,9 +211,23 @@ export default function Sidebar() {
|
|
|
165
211
|
name: activeWs.driveConfig.subFolderName ?? "",
|
|
166
212
|
}
|
|
167
213
|
: null;
|
|
214
|
+
const isAtDriveFolderRoot = isDriveWs && !currentSubFolder;
|
|
215
|
+
const canSyncDriveFolder =
|
|
216
|
+
!!activeWs?.driveConfig?.folderId && !isAtDriveFolderRoot;
|
|
217
|
+
const driveSyncTargetName =
|
|
218
|
+
currentSubFolder?.name ?? activeWs?.driveConfig?.folderName ?? "Drive";
|
|
168
219
|
const [navigating, setNavigating] = useState(false);
|
|
169
220
|
const [syncing, setSyncing] = useState(false);
|
|
221
|
+
const [pushing, setPushing] = useState(false);
|
|
222
|
+
const [topicSyncingId, setTopicSyncingId] = useState<string | null>(null);
|
|
223
|
+
const [topicPushingId, setTopicPushingId] = useState<string | null>(null);
|
|
224
|
+
const [topicDriveStatus, setTopicDriveStatus] = useState<
|
|
225
|
+
Record<string, string>
|
|
226
|
+
>({});
|
|
170
227
|
const [wsFilesExpanded, setWsFilesExpanded] = useState(true);
|
|
228
|
+
const [driveFileSyncStatus, setDriveFileSyncStatus] = useState<string | null>(
|
|
229
|
+
null,
|
|
230
|
+
);
|
|
171
231
|
|
|
172
232
|
// Load root folders whenever a Drive workspace becomes active with no subfolder selected
|
|
173
233
|
useEffect(() => {
|
|
@@ -197,13 +257,106 @@ export default function Sidebar() {
|
|
|
197
257
|
|
|
198
258
|
const handleResync = async () => {
|
|
199
259
|
setSyncing(true);
|
|
260
|
+
setDriveFileSyncStatus(null);
|
|
200
261
|
try {
|
|
201
|
-
await syncWorkspace(activeWorkspaceId!);
|
|
262
|
+
const result = await syncWorkspace(activeWorkspaceId!);
|
|
263
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
264
|
+
window.location.href = result.authUrl;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const firstError = result.errors[0];
|
|
268
|
+
setDriveFileSyncStatus(
|
|
269
|
+
result.errors.length > 0
|
|
270
|
+
? `Pull finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}. ${firstError ? `First: ${firstError}` : ""}`
|
|
271
|
+
: `Pulled ${result.filesImported} file${result.filesImported === 1 ? "" : "s"} from Drive.`,
|
|
272
|
+
);
|
|
273
|
+
} catch (err: any) {
|
|
274
|
+
setDriveFileSyncStatus(err?.message || "Pull from Drive failed.");
|
|
202
275
|
} finally {
|
|
203
276
|
setSyncing(false);
|
|
204
277
|
}
|
|
205
278
|
};
|
|
206
279
|
|
|
280
|
+
const handlePushToDrive = async () => {
|
|
281
|
+
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
282
|
+
setPushing(true);
|
|
283
|
+
setDriveFileSyncStatus(null);
|
|
284
|
+
try {
|
|
285
|
+
const result = await exportWorkspace(
|
|
286
|
+
activeWorkspaceId,
|
|
287
|
+
activeWs.driveConfig.subFolderId,
|
|
288
|
+
);
|
|
289
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
290
|
+
window.location.href = result.authUrl;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
setDriveFileSyncStatus(
|
|
294
|
+
result.errors.length > 0
|
|
295
|
+
? `Push finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}.`
|
|
296
|
+
: `Pushed ${result.filesExported} file${result.filesExported === 1 ? "" : "s"} to Drive.`,
|
|
297
|
+
);
|
|
298
|
+
} catch (err: any) {
|
|
299
|
+
setDriveFileSyncStatus(err?.message || "Push to Drive failed.");
|
|
300
|
+
} finally {
|
|
301
|
+
setPushing(false);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const setTopicStatus = (topicId: string, value: string) => {
|
|
306
|
+
setTopicDriveStatus((prev) => ({ ...prev, [topicId]: value }));
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const handlePullTopicFromDrive = async (topicId: string) => {
|
|
310
|
+
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
311
|
+
setTopicSyncingId(topicId);
|
|
312
|
+
setTopicStatus(topicId, "Pulling topic from Drive…");
|
|
313
|
+
try {
|
|
314
|
+
const result = await syncTopic(activeWorkspaceId, topicId);
|
|
315
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
316
|
+
window.location.href = result.authUrl;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const firstError = result.errors[0];
|
|
320
|
+
setTopicStatus(
|
|
321
|
+
topicId,
|
|
322
|
+
result.errors.length > 0
|
|
323
|
+
? `Topic pull finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}. ${firstError ? `First: ${firstError}` : ""}`
|
|
324
|
+
: `Pulled ${result.filesImported} file${result.filesImported === 1 ? "" : "s"} into this topic.`,
|
|
325
|
+
);
|
|
326
|
+
} catch (err: any) {
|
|
327
|
+
setTopicStatus(topicId, err?.message || "Topic pull failed.");
|
|
328
|
+
} finally {
|
|
329
|
+
setTopicSyncingId(null);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const handlePushTopicToDrive = async (topicId: string) => {
|
|
334
|
+
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
335
|
+
setTopicPushingId(topicId);
|
|
336
|
+
setTopicStatus(topicId, "Pushing topic to Drive…");
|
|
337
|
+
try {
|
|
338
|
+
const result = await exportTopic(
|
|
339
|
+
activeWorkspaceId,
|
|
340
|
+
topicId,
|
|
341
|
+
activeWs.driveConfig.subFolderId,
|
|
342
|
+
);
|
|
343
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
344
|
+
window.location.href = result.authUrl;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
setTopicStatus(
|
|
348
|
+
topicId,
|
|
349
|
+
result.errors.length > 0
|
|
350
|
+
? `Topic push finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}.`
|
|
351
|
+
: `Pushed ${result.questionsExported} question${result.questionsExported === 1 ? "" : "s"} and ${result.filesExported} file${result.filesExported === 1 ? "" : "s"} to Drive.`,
|
|
352
|
+
);
|
|
353
|
+
} catch (err: any) {
|
|
354
|
+
setTopicStatus(topicId, err?.message || "Topic push failed.");
|
|
355
|
+
} finally {
|
|
356
|
+
setTopicPushingId(null);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
207
360
|
useEffect(() => {
|
|
208
361
|
if (editingTopicId || editingQuestionId) {
|
|
209
362
|
editInputRef.current?.select();
|
|
@@ -296,6 +449,37 @@ export default function Sidebar() {
|
|
|
296
449
|
setMovingQuestionId(null);
|
|
297
450
|
};
|
|
298
451
|
|
|
452
|
+
const handleCopyQuestion = async (
|
|
453
|
+
topicId: string,
|
|
454
|
+
questionId: string,
|
|
455
|
+
targetParentId: string | null,
|
|
456
|
+
targetTopicId = topicId,
|
|
457
|
+
) => {
|
|
458
|
+
setCopyingNowId(questionId);
|
|
459
|
+
try {
|
|
460
|
+
await copyQuestion(questionId, topicId, targetParentId, targetTopicId);
|
|
461
|
+
setCopyingQuestionId(null);
|
|
462
|
+
setCopyingToTopicQuestionId(null);
|
|
463
|
+
setCopyTargetParentId(ROOT_PARENT_VALUE);
|
|
464
|
+
setCopyTargetTopicParentId(ROOT_PARENT_VALUE);
|
|
465
|
+
} finally {
|
|
466
|
+
setCopyingNowId(null);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const openCopyToTopicPicker = (q: Question, topicId: string) => {
|
|
471
|
+
const defaultTargetTopicId =
|
|
472
|
+
topics.find((topic) => topic.id !== topicId)?.id ?? "";
|
|
473
|
+
setMovingQuestionId(null);
|
|
474
|
+
setCopyingQuestionId(null);
|
|
475
|
+
setCopyingToTopicQuestionId((prev) => (prev === q.id ? null : q.id));
|
|
476
|
+
setCopyTargetTopicId(defaultTargetTopicId);
|
|
477
|
+
setCopyTargetTopicParentId(ROOT_PARENT_VALUE);
|
|
478
|
+
if (defaultTargetTopicId && !questionsByTopic[defaultTargetTopicId]) {
|
|
479
|
+
void fetchQuestions(defaultTargetTopicId);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
299
483
|
const renderMoveQuestionPicker = (
|
|
300
484
|
questions: Question[],
|
|
301
485
|
q: Question,
|
|
@@ -357,6 +541,179 @@ export default function Sidebar() {
|
|
|
357
541
|
);
|
|
358
542
|
};
|
|
359
543
|
|
|
544
|
+
const renderCopyQuestionPicker = (
|
|
545
|
+
questions: Question[],
|
|
546
|
+
q: Question,
|
|
547
|
+
topicId: string,
|
|
548
|
+
depth: number,
|
|
549
|
+
) => {
|
|
550
|
+
if (copyingQuestionId !== q.id) return null;
|
|
551
|
+
const parentOptions = buildMoveParentOptions(questions, new Set(), null, 0);
|
|
552
|
+
const targetParentId =
|
|
553
|
+
copyTargetParentId === ROOT_PARENT_VALUE ? null : copyTargetParentId;
|
|
554
|
+
const isCopying = copyingNowId === q.id;
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
<div
|
|
558
|
+
className="pr-2 py-1.5 animate-fadeIn"
|
|
559
|
+
style={{ paddingLeft: 12 + (depth + 1) * 16 }}
|
|
560
|
+
>
|
|
561
|
+
<div className="rounded border border-slate-700 bg-slate-800/70 p-2 space-y-2">
|
|
562
|
+
<div>
|
|
563
|
+
<div className="text-[11px] text-slate-500">Copy under</div>
|
|
564
|
+
<p className="mt-0.5 text-[10px] leading-snug text-slate-600">
|
|
565
|
+
Duplicates this question, its children, messages, and attached
|
|
566
|
+
files.
|
|
567
|
+
</p>
|
|
568
|
+
</div>
|
|
569
|
+
<select
|
|
570
|
+
autoFocus
|
|
571
|
+
value={copyTargetParentId}
|
|
572
|
+
onChange={(e) => setCopyTargetParentId(e.target.value)}
|
|
573
|
+
disabled={isCopying}
|
|
574
|
+
className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-cyan-500 disabled:opacity-60"
|
|
575
|
+
>
|
|
576
|
+
<option value={ROOT_PARENT_VALUE}>No parent (top level)</option>
|
|
577
|
+
{parentOptions.map((option) => (
|
|
578
|
+
<option key={option.id} value={option.id}>
|
|
579
|
+
{option.title}
|
|
580
|
+
</option>
|
|
581
|
+
))}
|
|
582
|
+
</select>
|
|
583
|
+
<div className="flex items-center justify-end gap-2">
|
|
584
|
+
<button
|
|
585
|
+
type="button"
|
|
586
|
+
onClick={() => setCopyingQuestionId(null)}
|
|
587
|
+
disabled={isCopying}
|
|
588
|
+
className="px-2 py-1 rounded text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
|
589
|
+
>
|
|
590
|
+
Cancel
|
|
591
|
+
</button>
|
|
592
|
+
<button
|
|
593
|
+
type="button"
|
|
594
|
+
onClick={() => handleCopyQuestion(topicId, q.id, targetParentId)}
|
|
595
|
+
disabled={isCopying}
|
|
596
|
+
className="px-2 py-1 rounded text-[11px] bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors inline-flex items-center gap-1.5"
|
|
597
|
+
>
|
|
598
|
+
{isCopying && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
599
|
+
Copy
|
|
600
|
+
</button>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
</div>
|
|
604
|
+
);
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const renderCopyToTopicPicker = (
|
|
608
|
+
q: Question,
|
|
609
|
+
sourceTopicId: string,
|
|
610
|
+
depth: number,
|
|
611
|
+
) => {
|
|
612
|
+
if (copyingToTopicQuestionId !== q.id) return null;
|
|
613
|
+
const destinationTopics = topics.filter(
|
|
614
|
+
(topic) => topic.id !== sourceTopicId,
|
|
615
|
+
);
|
|
616
|
+
const selectedTopicId = copyTargetTopicId || destinationTopics[0]?.id || "";
|
|
617
|
+
const targetQuestions = selectedTopicId
|
|
618
|
+
? (questionsByTopic[selectedTopicId] ?? [])
|
|
619
|
+
: [];
|
|
620
|
+
const parentOptions = buildMoveParentOptions(
|
|
621
|
+
targetQuestions,
|
|
622
|
+
new Set(),
|
|
623
|
+
null,
|
|
624
|
+
0,
|
|
625
|
+
);
|
|
626
|
+
const targetParentId =
|
|
627
|
+
copyTargetTopicParentId === ROOT_PARENT_VALUE
|
|
628
|
+
? null
|
|
629
|
+
: copyTargetTopicParentId;
|
|
630
|
+
const isCopying = copyingNowId === q.id;
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<div
|
|
634
|
+
className="pr-2 py-1.5 animate-fadeIn"
|
|
635
|
+
style={{ paddingLeft: 12 + (depth + 1) * 16 }}
|
|
636
|
+
>
|
|
637
|
+
<div className="rounded border border-slate-700 bg-slate-800/70 p-2 space-y-2">
|
|
638
|
+
<div>
|
|
639
|
+
<div className="text-[11px] text-slate-500">Copy to topic</div>
|
|
640
|
+
<p className="mt-0.5 text-[10px] leading-snug text-slate-600">
|
|
641
|
+
Creates an independent copy in another topic, including children,
|
|
642
|
+
messages, and attached files.
|
|
643
|
+
</p>
|
|
644
|
+
</div>
|
|
645
|
+
{destinationTopics.length > 0 ? (
|
|
646
|
+
<>
|
|
647
|
+
<select
|
|
648
|
+
autoFocus
|
|
649
|
+
value={selectedTopicId}
|
|
650
|
+
onChange={(e) => {
|
|
651
|
+
const nextTopicId = e.target.value;
|
|
652
|
+
setCopyTargetTopicId(nextTopicId);
|
|
653
|
+
setCopyTargetTopicParentId(ROOT_PARENT_VALUE);
|
|
654
|
+
if (nextTopicId && !questionsByTopic[nextTopicId]) {
|
|
655
|
+
void fetchQuestions(nextTopicId);
|
|
656
|
+
}
|
|
657
|
+
}}
|
|
658
|
+
disabled={isCopying}
|
|
659
|
+
className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-cyan-500 disabled:opacity-60"
|
|
660
|
+
>
|
|
661
|
+
{destinationTopics.map((topic) => (
|
|
662
|
+
<option key={topic.id} value={topic.id}>
|
|
663
|
+
{topic.name}
|
|
664
|
+
</option>
|
|
665
|
+
))}
|
|
666
|
+
</select>
|
|
667
|
+
<select
|
|
668
|
+
value={copyTargetTopicParentId}
|
|
669
|
+
onChange={(e) => setCopyTargetTopicParentId(e.target.value)}
|
|
670
|
+
disabled={isCopying || !selectedTopicId}
|
|
671
|
+
className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-cyan-500 disabled:opacity-60"
|
|
672
|
+
>
|
|
673
|
+
<option value={ROOT_PARENT_VALUE}>No parent (top level)</option>
|
|
674
|
+
{parentOptions.map((option) => (
|
|
675
|
+
<option key={option.id} value={option.id}>
|
|
676
|
+
{option.title}
|
|
677
|
+
</option>
|
|
678
|
+
))}
|
|
679
|
+
</select>
|
|
680
|
+
</>
|
|
681
|
+
) : (
|
|
682
|
+
<p className="rounded border border-slate-700 bg-slate-900/70 px-2 py-1.5 text-[11px] text-slate-500">
|
|
683
|
+
Create another topic first, then copy this question into it.
|
|
684
|
+
</p>
|
|
685
|
+
)}
|
|
686
|
+
<div className="flex items-center justify-end gap-2">
|
|
687
|
+
<button
|
|
688
|
+
type="button"
|
|
689
|
+
onClick={() => setCopyingToTopicQuestionId(null)}
|
|
690
|
+
disabled={isCopying}
|
|
691
|
+
className="px-2 py-1 rounded text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
|
692
|
+
>
|
|
693
|
+
Cancel
|
|
694
|
+
</button>
|
|
695
|
+
<button
|
|
696
|
+
type="button"
|
|
697
|
+
onClick={() =>
|
|
698
|
+
handleCopyQuestion(
|
|
699
|
+
sourceTopicId,
|
|
700
|
+
q.id,
|
|
701
|
+
targetParentId,
|
|
702
|
+
selectedTopicId,
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
disabled={isCopying || !selectedTopicId}
|
|
706
|
+
className="px-2 py-1 rounded text-[11px] bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors inline-flex items-center gap-1.5"
|
|
707
|
+
>
|
|
708
|
+
{isCopying && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
709
|
+
Copy
|
|
710
|
+
</button>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
</div>
|
|
714
|
+
);
|
|
715
|
+
};
|
|
716
|
+
|
|
360
717
|
const renderQuestionRow = (
|
|
361
718
|
q: Question,
|
|
362
719
|
topicId: string,
|
|
@@ -491,6 +848,8 @@ export default function Sidebar() {
|
|
|
491
848
|
<button
|
|
492
849
|
onClick={() => {
|
|
493
850
|
setOpenMenuQuestionId(null);
|
|
851
|
+
setCopyingQuestionId(null);
|
|
852
|
+
setCopyingToTopicQuestionId(null);
|
|
494
853
|
setMovingQuestionId((prev) =>
|
|
495
854
|
prev === q.id ? null : q.id,
|
|
496
855
|
);
|
|
@@ -502,13 +861,45 @@ export default function Sidebar() {
|
|
|
502
861
|
>
|
|
503
862
|
<ArrowRightLeft className="w-3 h-3" /> Move
|
|
504
863
|
</button>
|
|
505
|
-
<div className="border-t border-slate-700 my-0.5" />
|
|
506
864
|
<button
|
|
507
865
|
onClick={() => {
|
|
508
866
|
setOpenMenuQuestionId(null);
|
|
509
|
-
|
|
867
|
+
setMovingQuestionId(null);
|
|
868
|
+
setCopyingToTopicQuestionId(null);
|
|
869
|
+
setCopyingQuestionId((prev) =>
|
|
870
|
+
prev === q.id ? null : q.id,
|
|
871
|
+
);
|
|
872
|
+
setCopyTargetParentId(
|
|
873
|
+
q.parentQuestionId ?? ROOT_PARENT_VALUE,
|
|
874
|
+
);
|
|
875
|
+
}}
|
|
876
|
+
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"
|
|
877
|
+
>
|
|
878
|
+
<Copy className="w-3 h-3" /> Copy in topic
|
|
879
|
+
</button>
|
|
880
|
+
<button
|
|
881
|
+
onClick={() => {
|
|
882
|
+
setOpenMenuQuestionId(null);
|
|
883
|
+
openCopyToTopicPicker(q, topicId);
|
|
884
|
+
}}
|
|
885
|
+
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"
|
|
886
|
+
>
|
|
887
|
+
<Copy className="w-3 h-3" /> Copy to topic
|
|
888
|
+
</button>
|
|
889
|
+
<div className="border-t border-slate-700 my-0.5" />
|
|
890
|
+
<button
|
|
891
|
+
onClick={async () => {
|
|
892
|
+
setOpenMenuQuestionId(null);
|
|
893
|
+
let allQ = questionsByTopic[topicId] ?? [];
|
|
894
|
+
try {
|
|
895
|
+
allQ = await api.fetchQuestions(topicId);
|
|
896
|
+
} catch {
|
|
897
|
+
// Fall back to the already-loaded sidebar snapshot.
|
|
898
|
+
}
|
|
899
|
+
const latestQuestion =
|
|
900
|
+
allQ.find((candidate) => candidate.id === q.id) ?? q;
|
|
510
901
|
downloadJson(
|
|
511
|
-
buildQuestionExport(
|
|
902
|
+
await buildQuestionExport(latestQuestion, allQ),
|
|
512
903
|
`${q.title.replace(/[^a-z0-9]+/gi, "-")}.json`,
|
|
513
904
|
);
|
|
514
905
|
}}
|
|
@@ -618,6 +1009,8 @@ export default function Sidebar() {
|
|
|
618
1009
|
</div>
|
|
619
1010
|
)}
|
|
620
1011
|
{renderMoveQuestionPicker(questions, q, topicId, depth)}
|
|
1012
|
+
{renderCopyQuestionPicker(questions, q, topicId, depth)}
|
|
1013
|
+
{renderCopyToTopicPicker(q, topicId, depth)}
|
|
621
1014
|
{/* Recurse into children — hidden when collapsed */}
|
|
622
1015
|
{!isCollapsed &&
|
|
623
1016
|
renderQuestionSubtree(questions, topicId, q.id, depth + 1)}
|
|
@@ -661,6 +1054,54 @@ export default function Sidebar() {
|
|
|
661
1054
|
downloadBase="/api/workspace/context-files"
|
|
662
1055
|
label="workspace"
|
|
663
1056
|
/>
|
|
1057
|
+
{activeWs?.driveConfig?.folderId && isAtDriveFolderRoot && (
|
|
1058
|
+
<p className="mt-2 rounded-md border border-slate-800 bg-slate-900/70 px-2 py-1.5 text-[10px] leading-relaxed text-slate-500">
|
|
1059
|
+
Select a Drive folder first. Its workspace-files and questions
|
|
1060
|
+
sync from inside that selected folder.
|
|
1061
|
+
</p>
|
|
1062
|
+
)}
|
|
1063
|
+
{canSyncDriveFolder && (
|
|
1064
|
+
<div className="mt-2 space-y-1.5">
|
|
1065
|
+
<p className="text-[10px] text-slate-600">
|
|
1066
|
+
Sync selected folder: {driveSyncTargetName}
|
|
1067
|
+
</p>
|
|
1068
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
1069
|
+
<button
|
|
1070
|
+
type="button"
|
|
1071
|
+
onClick={handlePushToDrive}
|
|
1072
|
+
disabled={pushing || syncing}
|
|
1073
|
+
className="flex items-center justify-center gap-1 rounded-md border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[11px] font-medium text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:hover:bg-cyan-500/10 transition-colors"
|
|
1074
|
+
title="Push topics, questions, and workspace files into the selected Drive folder"
|
|
1075
|
+
>
|
|
1076
|
+
{pushing ? (
|
|
1077
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1078
|
+
) : (
|
|
1079
|
+
<Upload className="w-3 h-3" />
|
|
1080
|
+
)}
|
|
1081
|
+
{pushing ? "Pushing…" : "Push folder"}
|
|
1082
|
+
</button>
|
|
1083
|
+
<button
|
|
1084
|
+
type="button"
|
|
1085
|
+
onClick={handleResync}
|
|
1086
|
+
disabled={syncing || pushing}
|
|
1087
|
+
className="flex items-center justify-center gap-1 rounded-md border border-slate-700 bg-slate-800/60 px-2 py-1 text-[11px] font-medium text-slate-300 hover:border-cyan-500/30 hover:text-cyan-300 disabled:opacity-40 disabled:hover:text-slate-300 transition-colors"
|
|
1088
|
+
title="Pull topics, questions, and workspace files from the selected Drive folder"
|
|
1089
|
+
>
|
|
1090
|
+
{syncing ? (
|
|
1091
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1092
|
+
) : (
|
|
1093
|
+
<RefreshCw className="w-3 h-3" />
|
|
1094
|
+
)}
|
|
1095
|
+
{syncing ? "Pulling…" : "Pull folder"}
|
|
1096
|
+
</button>
|
|
1097
|
+
</div>
|
|
1098
|
+
{driveFileSyncStatus && (
|
|
1099
|
+
<p className="text-[10px] leading-relaxed text-slate-500">
|
|
1100
|
+
{driveFileSyncStatus}
|
|
1101
|
+
</p>
|
|
1102
|
+
)}
|
|
1103
|
+
</div>
|
|
1104
|
+
)}
|
|
664
1105
|
</div>
|
|
665
1106
|
)}
|
|
666
1107
|
</div>
|
|
@@ -816,6 +1257,9 @@ export default function Sidebar() {
|
|
|
816
1257
|
sensitivity: "base",
|
|
817
1258
|
}),
|
|
818
1259
|
);
|
|
1260
|
+
const isTopicMenuOpen = openMenuTopicId === topic.id;
|
|
1261
|
+
const topicBusy =
|
|
1262
|
+
topicSyncingId === topic.id || topicPushingId === topic.id;
|
|
819
1263
|
|
|
820
1264
|
return (
|
|
821
1265
|
<div key={topic.id}>
|
|
@@ -860,61 +1304,160 @@ export default function Sidebar() {
|
|
|
860
1304
|
</span>
|
|
861
1305
|
</button>
|
|
862
1306
|
{editingTopicId !== topic.id && (
|
|
863
|
-
<
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
setEditingTopicId(topic.id);
|
|
867
|
-
setEditingTopicName(topic.name);
|
|
868
|
-
}}
|
|
869
|
-
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
870
|
-
title="Rename"
|
|
1307
|
+
<div
|
|
1308
|
+
className="relative shrink-0 flex items-center"
|
|
1309
|
+
onClick={(e) => e.stopPropagation()}
|
|
871
1310
|
>
|
|
872
|
-
|
|
873
|
-
|
|
1311
|
+
{topicBusy && !isTopicMenuOpen ? (
|
|
1312
|
+
<Loader2 className="w-3 h-3 animate-spin text-cyan-400" />
|
|
1313
|
+
) : (
|
|
1314
|
+
<button
|
|
1315
|
+
onClick={() =>
|
|
1316
|
+
setOpenMenuTopicId(
|
|
1317
|
+
isTopicMenuOpen ? null : topic.id,
|
|
1318
|
+
)
|
|
1319
|
+
}
|
|
1320
|
+
className={`p-0.5 rounded transition-all ${
|
|
1321
|
+
isTopicMenuOpen
|
|
1322
|
+
? "text-cyan-400"
|
|
1323
|
+
: "opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-300"
|
|
1324
|
+
}`}
|
|
1325
|
+
title="Topic options"
|
|
1326
|
+
>
|
|
1327
|
+
<MoreHorizontal className="w-3.5 h-3.5" />
|
|
1328
|
+
</button>
|
|
1329
|
+
)}
|
|
1330
|
+
|
|
1331
|
+
{isTopicMenuOpen && (
|
|
1332
|
+
<>
|
|
1333
|
+
<div
|
|
1334
|
+
className="fixed inset-0 z-40"
|
|
1335
|
+
onClick={() => setOpenMenuTopicId(null)}
|
|
1336
|
+
/>
|
|
1337
|
+
<div className="absolute right-0 top-full mt-0.5 z-50 bg-slate-800 border border-slate-700 rounded-md shadow-xl min-w-[170px] py-0.5">
|
|
1338
|
+
<button
|
|
1339
|
+
onClick={() => {
|
|
1340
|
+
setOpenMenuTopicId(null);
|
|
1341
|
+
setEditingTopicId(topic.id);
|
|
1342
|
+
setEditingTopicName(topic.name);
|
|
1343
|
+
}}
|
|
1344
|
+
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"
|
|
1345
|
+
>
|
|
1346
|
+
<Pencil className="w-3 h-3" /> Rename
|
|
1347
|
+
</button>
|
|
1348
|
+
<button
|
|
1349
|
+
onClick={() => {
|
|
1350
|
+
setOpenMenuTopicId(null);
|
|
1351
|
+
setAddingQuestionTo(topic.id);
|
|
1352
|
+
setNewQuestionTitle("");
|
|
1353
|
+
if (!isExpanded) toggleTopic(topic.id);
|
|
1354
|
+
}}
|
|
1355
|
+
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"
|
|
1356
|
+
>
|
|
1357
|
+
<Plus className="w-3 h-3" /> Add question
|
|
1358
|
+
</button>
|
|
1359
|
+
<button
|
|
1360
|
+
onClick={async () => {
|
|
1361
|
+
setOpenMenuTopicId(null);
|
|
1362
|
+
let topicQuestions =
|
|
1363
|
+
questionsByTopic[topic.id] ?? [];
|
|
1364
|
+
try {
|
|
1365
|
+
topicQuestions = await api.fetchQuestions(
|
|
1366
|
+
topic.id,
|
|
1367
|
+
);
|
|
1368
|
+
} catch {
|
|
1369
|
+
// Fall back to the already-loaded sidebar snapshot.
|
|
1370
|
+
}
|
|
1371
|
+
const rootQuestions = topicQuestions.filter(
|
|
1372
|
+
(q) => !q.parentQuestionId,
|
|
1373
|
+
);
|
|
1374
|
+
const exportedQuestions = await Promise.all(
|
|
1375
|
+
rootQuestions.map((q) =>
|
|
1376
|
+
buildQuestionExport(q, topicQuestions),
|
|
1377
|
+
),
|
|
1378
|
+
);
|
|
1379
|
+
downloadJson(
|
|
1380
|
+
{
|
|
1381
|
+
id: topic.id,
|
|
1382
|
+
name: topic.name,
|
|
1383
|
+
systemContext: topic.systemContext ?? "",
|
|
1384
|
+
contextFiles: topic.contextFiles,
|
|
1385
|
+
createdAt: topic.createdAt,
|
|
1386
|
+
questions: exportedQuestions,
|
|
1387
|
+
},
|
|
1388
|
+
`${topic.name.replace(/[^a-z0-9]+/gi, "-")}.json`,
|
|
1389
|
+
);
|
|
1390
|
+
}}
|
|
1391
|
+
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"
|
|
1392
|
+
>
|
|
1393
|
+
<Download className="w-3 h-3" /> Download
|
|
1394
|
+
</button>
|
|
1395
|
+
|
|
1396
|
+
{canSyncDriveFolder && (
|
|
1397
|
+
<>
|
|
1398
|
+
<div className="border-t border-slate-700 my-0.5" />
|
|
1399
|
+
<button
|
|
1400
|
+
onClick={() => {
|
|
1401
|
+
setOpenMenuTopicId(null);
|
|
1402
|
+
void handlePushTopicToDrive(topic.id);
|
|
1403
|
+
}}
|
|
1404
|
+
disabled={topicBusy || pushing || syncing}
|
|
1405
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-cyan-300 hover:bg-slate-700 hover:text-cyan-200 disabled:opacity-50 transition-colors"
|
|
1406
|
+
>
|
|
1407
|
+
{topicPushingId === topic.id ? (
|
|
1408
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1409
|
+
) : (
|
|
1410
|
+
<Upload className="w-3 h-3" />
|
|
1411
|
+
)}
|
|
1412
|
+
Push topic
|
|
1413
|
+
</button>
|
|
1414
|
+
<button
|
|
1415
|
+
onClick={() => {
|
|
1416
|
+
setOpenMenuTopicId(null);
|
|
1417
|
+
void handlePullTopicFromDrive(topic.id);
|
|
1418
|
+
}}
|
|
1419
|
+
disabled={topicBusy || pushing || syncing}
|
|
1420
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-cyan-300 disabled:opacity-50 transition-colors"
|
|
1421
|
+
>
|
|
1422
|
+
{topicSyncingId === topic.id ? (
|
|
1423
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1424
|
+
) : (
|
|
1425
|
+
<RefreshCw className="w-3 h-3" />
|
|
1426
|
+
)}
|
|
1427
|
+
Pull topic
|
|
1428
|
+
</button>
|
|
1429
|
+
</>
|
|
1430
|
+
)}
|
|
1431
|
+
|
|
1432
|
+
<div className="border-t border-slate-700 my-0.5" />
|
|
1433
|
+
<button
|
|
1434
|
+
onClick={() => {
|
|
1435
|
+
setOpenMenuTopicId(null);
|
|
1436
|
+
if (
|
|
1437
|
+
confirm(
|
|
1438
|
+
`Delete topic "${topic.name}" and all its questions?`,
|
|
1439
|
+
)
|
|
1440
|
+
) {
|
|
1441
|
+
removeTopic(topic.id);
|
|
1442
|
+
}
|
|
1443
|
+
}}
|
|
1444
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-slate-700 hover:text-red-300 transition-colors"
|
|
1445
|
+
>
|
|
1446
|
+
<Trash2 className="w-3 h-3" /> Delete
|
|
1447
|
+
</button>
|
|
1448
|
+
</div>
|
|
1449
|
+
</>
|
|
1450
|
+
)}
|
|
1451
|
+
</div>
|
|
874
1452
|
)}
|
|
875
|
-
<button
|
|
876
|
-
onClick={(e) => {
|
|
877
|
-
e.stopPropagation();
|
|
878
|
-
if (
|
|
879
|
-
confirm(
|
|
880
|
-
`Delete topic "${topic.name}" and all its questions?`,
|
|
881
|
-
)
|
|
882
|
-
) {
|
|
883
|
-
removeTopic(topic.id);
|
|
884
|
-
}
|
|
885
|
-
}}
|
|
886
|
-
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
|
|
887
|
-
>
|
|
888
|
-
<Trash2 className="w-3 h-3" />
|
|
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>
|
|
916
1453
|
</div>
|
|
917
1454
|
|
|
1455
|
+
{topicDriveStatus[topic.id] && (
|
|
1456
|
+
<p className="px-3 pb-1 text-[10px] leading-relaxed text-slate-500">
|
|
1457
|
+
{topicDriveStatus[topic.id]}
|
|
1458
|
+
</p>
|
|
1459
|
+
)}
|
|
1460
|
+
|
|
918
1461
|
{/* Questions list */}
|
|
919
1462
|
{isExpanded && (
|
|
920
1463
|
<div className="ml-3 border-l border-slate-800">
|