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.
@@ -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 buildQuestionExport(q: Question, allQuestions: Question[]): object {
45
- const children = allQuestions
46
- .filter((c) => c.parentQuestionId === q.id)
47
- .map((c) => buildQuestionExport(c, allQuestions));
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.contextFiles,
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
- const allQ = questionsByTopic[topicId] ?? [];
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(q, allQ),
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
- <button
864
- onClick={(e) => {
865
- e.stopPropagation();
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
- <Pencil className="w-3 h-3" />
873
- </button>
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">