create-interview-cockpit 0.17.3 → 0.18.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,8 @@
1
1
  import { useState } from "react";
2
2
  import { useStore } from "../store";
3
- import { parseInfraLabWorkspace } from "../infraLab";
3
+ import { DOCKER_DEEP_DIVE_LAB, parseInfraLabWorkspace } from "../infraLab";
4
+ import { DEFAULT_GHA_LAB, parseGhaLabWorkspace } from "../githubActionsLab";
5
+ import { ENTERPRISE_LOCAL_AUTH_LAB } from "../enterpriseLocalLab";
4
6
  import {
5
7
  parseFrontendLabWorkspace,
6
8
  ISOLATED_MODULE_FEDERATION_LAB,
@@ -8,6 +10,7 @@ import {
8
10
  NEXTJS_MF_RUNTIME_LAB,
9
11
  NEXTJS_MULTI_ZONES_LAB,
10
12
  NEXTJS_MF_RUNTIME_API_LAB,
13
+ NEXTJS_BFF_AUTH_CLIENT_LAB,
11
14
  RSPACK_SHELL_LAB,
12
15
  } from "../reactLab";
13
16
  import { BROWSER_SECURITY_TEMPLATES } from "../browserSecurityTemplates";
@@ -29,6 +32,7 @@ import {
29
32
  Network,
30
33
  Shield,
31
34
  PenLine,
35
+ GitBranch,
32
36
  } from "lucide-react";
33
37
 
34
38
  // ─── Helpers ─────────────────────────────────────────────
@@ -41,6 +45,7 @@ const LAB_ORIGINS = new Set([
41
45
  "nextjs",
42
46
  "module-federation",
43
47
  "canvas",
48
+ "github-actions",
44
49
  ]);
45
50
 
46
51
  function isLabFile(cf: ContextFile) {
@@ -196,6 +201,7 @@ export default function LabsPanel() {
196
201
  currentQuestion,
197
202
  openSandbox,
198
203
  openInfraLab,
204
+ openGhaLab,
199
205
  openReactLab,
200
206
  openNextLab,
201
207
  openModuleFederationLab,
@@ -302,6 +308,18 @@ export default function LabsPanel() {
302
308
  if (parsed) openInfraLab(parsed, cf.id);
303
309
  };
304
310
 
311
+ const openGhaFile = async (cf: ContextFile) => {
312
+ try {
313
+ const raw = await fetch(`/api/context-files/${cf.id}/content`)
314
+ .then((r) => r.json())
315
+ .then((d) => d.content as string);
316
+ const parsed = parseGhaLabWorkspace(raw);
317
+ if (parsed) openGhaLab(parsed, cf.id);
318
+ } catch {
319
+ /* ignore */
320
+ }
321
+ };
322
+
305
323
  const openReactFile = async (cf: ContextFile) => {
306
324
  try {
307
325
  const raw = await fetch(`/api/context-files/${cf.id}/content`)
@@ -606,13 +624,50 @@ export default function LabsPanel() {
606
624
  iconColor="text-cyan-400/70"
607
625
  origin="infra"
608
626
  emptyText="Save an infra lab to reopen it here"
609
- onNewLab={() => openInfraLab()}
610
- newLabTitle="Open Infrastructure Lab"
627
+ newLabMenu={[
628
+ {
629
+ label: "AWS LocalStack S3",
630
+ description:
631
+ "Terraform AWS provider pointed at local emulation",
632
+ onClick: () => openInfraLab(),
633
+ },
634
+ {
635
+ label: "Docker Deep Dive",
636
+ description:
637
+ "Dockerfile + Compose + Node API + Redis, with command-line practice",
638
+ onClick: () => openInfraLab(DOCKER_DEEP_DIVE_LAB),
639
+ },
640
+ {
641
+ label: "Enterprise BFF Docker Stack",
642
+ description:
643
+ "Terraform deploys NestJS BFF, OIDC mock, Redis & claims API",
644
+ onClick: () => openInfraLab(ENTERPRISE_LOCAL_AUTH_LAB),
645
+ },
646
+ ]}
611
647
  onOpen={openInfraFile}
612
648
  openTitle="Open in Infrastructure Lab"
613
649
  accentClass="text-cyan-200"
614
650
  bgClass="bg-cyan-500/10 border border-cyan-500/20"
615
651
  />
652
+ <Section
653
+ title="GitHub Actions"
654
+ icon={GitBranch}
655
+ iconColor="text-amber-400/70"
656
+ origin="github-actions"
657
+ emptyText="Save a GitHub Actions lab to reopen it here"
658
+ newLabMenu={[
659
+ {
660
+ label: "Workflows + Composite Action",
661
+ description:
662
+ "Multi-job CI workflow with a local composite action and a matrix build",
663
+ onClick: () => openGhaLab(DEFAULT_GHA_LAB),
664
+ },
665
+ ]}
666
+ onOpen={openGhaFile}
667
+ openTitle="Open in GitHub Actions Lab"
668
+ accentClass="text-amber-200"
669
+ bgClass="bg-amber-500/10 border border-amber-500/20"
670
+ />
616
671
  <Section
617
672
  title="React Labs"
618
673
  icon={Atom}
@@ -632,8 +687,19 @@ export default function LabsPanel() {
632
687
  iconColor="text-violet-400/70"
633
688
  origin="nextjs"
634
689
  emptyText="Save a Next.js lab to reopen it here"
635
- onNewLab={() => openNextLab()}
636
- newLabTitle="Open Next.js Lab"
690
+ newLabMenu={[
691
+ {
692
+ label: "Blank App Router",
693
+ description: "Standard Next.js App Router practice lab",
694
+ onClick: () => openNextLab(),
695
+ },
696
+ {
697
+ label: "BFF Auth Client",
698
+ description:
699
+ "Next.js shell that signs in through the local Terraform-deployed BFF",
700
+ onClick: () => openNextLab(NEXTJS_BFF_AUTH_CLIENT_LAB),
701
+ },
702
+ ]}
637
703
  onOpen={openNextFile}
638
704
  openTitle="Open in Next.js Lab"
639
705
  accentClass="text-violet-200"
@@ -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,7 @@ export default function Sidebar() {
89
119
  selectDriveSubfolder,
90
120
  clearDriveSubfolder,
91
121
  syncWorkspace,
122
+ exportWorkspace,
92
123
  workspaceFiles,
93
124
  uploadWorkspaceFiles,
94
125
  removeWorkspaceFile,
@@ -113,6 +144,18 @@ export default function Sidebar() {
113
144
  const [movingQuestionId, setMovingQuestionId] = useState<string | null>(null);
114
145
  const [moveTargetParentId, setMoveTargetParentId] =
115
146
  useState(ROOT_PARENT_VALUE);
147
+ const [copyingQuestionId, setCopyingQuestionId] = useState<string | null>(
148
+ null,
149
+ );
150
+ const [copyTargetParentId, setCopyTargetParentId] =
151
+ useState(ROOT_PARENT_VALUE);
152
+ const [copyingToTopicQuestionId, setCopyingToTopicQuestionId] = useState<
153
+ string | null
154
+ >(null);
155
+ const [copyTargetTopicId, setCopyTargetTopicId] = useState("");
156
+ const [copyTargetTopicParentId, setCopyTargetTopicParentId] =
157
+ useState(ROOT_PARENT_VALUE);
158
+ const [copyingNowId, setCopyingNowId] = useState<string | null>(null);
116
159
  const [collapsedQuestions, setCollapsedQuestions] = useState<Set<string>>(
117
160
  new Set(),
118
161
  );
@@ -165,9 +208,18 @@ export default function Sidebar() {
165
208
  name: activeWs.driveConfig.subFolderName ?? "",
166
209
  }
167
210
  : null;
211
+ const isAtDriveFolderRoot = isDriveWs && !currentSubFolder;
212
+ const canSyncDriveFolder =
213
+ !!activeWs?.driveConfig?.folderId && !isAtDriveFolderRoot;
214
+ const driveSyncTargetName =
215
+ currentSubFolder?.name ?? activeWs?.driveConfig?.folderName ?? "Drive";
168
216
  const [navigating, setNavigating] = useState(false);
169
217
  const [syncing, setSyncing] = useState(false);
218
+ const [pushing, setPushing] = useState(false);
170
219
  const [wsFilesExpanded, setWsFilesExpanded] = useState(true);
220
+ const [driveFileSyncStatus, setDriveFileSyncStatus] = useState<string | null>(
221
+ null,
222
+ );
171
223
 
172
224
  // Load root folders whenever a Drive workspace becomes active with no subfolder selected
173
225
  useEffect(() => {
@@ -197,13 +249,51 @@ export default function Sidebar() {
197
249
 
198
250
  const handleResync = async () => {
199
251
  setSyncing(true);
252
+ setDriveFileSyncStatus(null);
200
253
  try {
201
- await syncWorkspace(activeWorkspaceId!);
254
+ const result = await syncWorkspace(activeWorkspaceId!);
255
+ if ("needsAuth" in result && result.needsAuth) {
256
+ window.location.href = result.authUrl;
257
+ return;
258
+ }
259
+ const firstError = result.errors[0];
260
+ setDriveFileSyncStatus(
261
+ result.errors.length > 0
262
+ ? `Pull finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}. ${firstError ? `First: ${firstError}` : ""}`
263
+ : `Pulled ${result.filesImported} file${result.filesImported === 1 ? "" : "s"} from Drive.`,
264
+ );
265
+ } catch (err: any) {
266
+ setDriveFileSyncStatus(err?.message || "Pull from Drive failed.");
202
267
  } finally {
203
268
  setSyncing(false);
204
269
  }
205
270
  };
206
271
 
272
+ const handlePushToDrive = async () => {
273
+ if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
274
+ setPushing(true);
275
+ setDriveFileSyncStatus(null);
276
+ try {
277
+ const result = await exportWorkspace(
278
+ activeWorkspaceId,
279
+ activeWs.driveConfig.subFolderId,
280
+ );
281
+ if ("needsAuth" in result && result.needsAuth) {
282
+ window.location.href = result.authUrl;
283
+ return;
284
+ }
285
+ setDriveFileSyncStatus(
286
+ result.errors.length > 0
287
+ ? `Push finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}.`
288
+ : `Pushed ${result.filesExported} file${result.filesExported === 1 ? "" : "s"} to Drive.`,
289
+ );
290
+ } catch (err: any) {
291
+ setDriveFileSyncStatus(err?.message || "Push to Drive failed.");
292
+ } finally {
293
+ setPushing(false);
294
+ }
295
+ };
296
+
207
297
  useEffect(() => {
208
298
  if (editingTopicId || editingQuestionId) {
209
299
  editInputRef.current?.select();
@@ -296,6 +386,37 @@ export default function Sidebar() {
296
386
  setMovingQuestionId(null);
297
387
  };
298
388
 
389
+ const handleCopyQuestion = async (
390
+ topicId: string,
391
+ questionId: string,
392
+ targetParentId: string | null,
393
+ targetTopicId = topicId,
394
+ ) => {
395
+ setCopyingNowId(questionId);
396
+ try {
397
+ await copyQuestion(questionId, topicId, targetParentId, targetTopicId);
398
+ setCopyingQuestionId(null);
399
+ setCopyingToTopicQuestionId(null);
400
+ setCopyTargetParentId(ROOT_PARENT_VALUE);
401
+ setCopyTargetTopicParentId(ROOT_PARENT_VALUE);
402
+ } finally {
403
+ setCopyingNowId(null);
404
+ }
405
+ };
406
+
407
+ const openCopyToTopicPicker = (q: Question, topicId: string) => {
408
+ const defaultTargetTopicId =
409
+ topics.find((topic) => topic.id !== topicId)?.id ?? "";
410
+ setMovingQuestionId(null);
411
+ setCopyingQuestionId(null);
412
+ setCopyingToTopicQuestionId((prev) => (prev === q.id ? null : q.id));
413
+ setCopyTargetTopicId(defaultTargetTopicId);
414
+ setCopyTargetTopicParentId(ROOT_PARENT_VALUE);
415
+ if (defaultTargetTopicId && !questionsByTopic[defaultTargetTopicId]) {
416
+ void fetchQuestions(defaultTargetTopicId);
417
+ }
418
+ };
419
+
299
420
  const renderMoveQuestionPicker = (
300
421
  questions: Question[],
301
422
  q: Question,
@@ -357,6 +478,179 @@ export default function Sidebar() {
357
478
  );
358
479
  };
359
480
 
481
+ const renderCopyQuestionPicker = (
482
+ questions: Question[],
483
+ q: Question,
484
+ topicId: string,
485
+ depth: number,
486
+ ) => {
487
+ if (copyingQuestionId !== q.id) return null;
488
+ const parentOptions = buildMoveParentOptions(questions, new Set(), null, 0);
489
+ const targetParentId =
490
+ copyTargetParentId === ROOT_PARENT_VALUE ? null : copyTargetParentId;
491
+ const isCopying = copyingNowId === q.id;
492
+
493
+ return (
494
+ <div
495
+ className="pr-2 py-1.5 animate-fadeIn"
496
+ style={{ paddingLeft: 12 + (depth + 1) * 16 }}
497
+ >
498
+ <div className="rounded border border-slate-700 bg-slate-800/70 p-2 space-y-2">
499
+ <div>
500
+ <div className="text-[11px] text-slate-500">Copy under</div>
501
+ <p className="mt-0.5 text-[10px] leading-snug text-slate-600">
502
+ Duplicates this question, its children, messages, and attached
503
+ files.
504
+ </p>
505
+ </div>
506
+ <select
507
+ autoFocus
508
+ value={copyTargetParentId}
509
+ onChange={(e) => setCopyTargetParentId(e.target.value)}
510
+ disabled={isCopying}
511
+ 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"
512
+ >
513
+ <option value={ROOT_PARENT_VALUE}>No parent (top level)</option>
514
+ {parentOptions.map((option) => (
515
+ <option key={option.id} value={option.id}>
516
+ {option.title}
517
+ </option>
518
+ ))}
519
+ </select>
520
+ <div className="flex items-center justify-end gap-2">
521
+ <button
522
+ type="button"
523
+ onClick={() => setCopyingQuestionId(null)}
524
+ disabled={isCopying}
525
+ className="px-2 py-1 rounded text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700 disabled:opacity-50 transition-colors"
526
+ >
527
+ Cancel
528
+ </button>
529
+ <button
530
+ type="button"
531
+ onClick={() => handleCopyQuestion(topicId, q.id, targetParentId)}
532
+ disabled={isCopying}
533
+ 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"
534
+ >
535
+ {isCopying && <Loader2 className="w-3 h-3 animate-spin" />}
536
+ Copy
537
+ </button>
538
+ </div>
539
+ </div>
540
+ </div>
541
+ );
542
+ };
543
+
544
+ const renderCopyToTopicPicker = (
545
+ q: Question,
546
+ sourceTopicId: string,
547
+ depth: number,
548
+ ) => {
549
+ if (copyingToTopicQuestionId !== q.id) return null;
550
+ const destinationTopics = topics.filter(
551
+ (topic) => topic.id !== sourceTopicId,
552
+ );
553
+ const selectedTopicId = copyTargetTopicId || destinationTopics[0]?.id || "";
554
+ const targetQuestions = selectedTopicId
555
+ ? (questionsByTopic[selectedTopicId] ?? [])
556
+ : [];
557
+ const parentOptions = buildMoveParentOptions(
558
+ targetQuestions,
559
+ new Set(),
560
+ null,
561
+ 0,
562
+ );
563
+ const targetParentId =
564
+ copyTargetTopicParentId === ROOT_PARENT_VALUE
565
+ ? null
566
+ : copyTargetTopicParentId;
567
+ const isCopying = copyingNowId === q.id;
568
+
569
+ return (
570
+ <div
571
+ className="pr-2 py-1.5 animate-fadeIn"
572
+ style={{ paddingLeft: 12 + (depth + 1) * 16 }}
573
+ >
574
+ <div className="rounded border border-slate-700 bg-slate-800/70 p-2 space-y-2">
575
+ <div>
576
+ <div className="text-[11px] text-slate-500">Copy to topic</div>
577
+ <p className="mt-0.5 text-[10px] leading-snug text-slate-600">
578
+ Creates an independent copy in another topic, including children,
579
+ messages, and attached files.
580
+ </p>
581
+ </div>
582
+ {destinationTopics.length > 0 ? (
583
+ <>
584
+ <select
585
+ autoFocus
586
+ value={selectedTopicId}
587
+ onChange={(e) => {
588
+ const nextTopicId = e.target.value;
589
+ setCopyTargetTopicId(nextTopicId);
590
+ setCopyTargetTopicParentId(ROOT_PARENT_VALUE);
591
+ if (nextTopicId && !questionsByTopic[nextTopicId]) {
592
+ void fetchQuestions(nextTopicId);
593
+ }
594
+ }}
595
+ disabled={isCopying}
596
+ 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"
597
+ >
598
+ {destinationTopics.map((topic) => (
599
+ <option key={topic.id} value={topic.id}>
600
+ {topic.name}
601
+ </option>
602
+ ))}
603
+ </select>
604
+ <select
605
+ value={copyTargetTopicParentId}
606
+ onChange={(e) => setCopyTargetTopicParentId(e.target.value)}
607
+ disabled={isCopying || !selectedTopicId}
608
+ 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"
609
+ >
610
+ <option value={ROOT_PARENT_VALUE}>No parent (top level)</option>
611
+ {parentOptions.map((option) => (
612
+ <option key={option.id} value={option.id}>
613
+ {option.title}
614
+ </option>
615
+ ))}
616
+ </select>
617
+ </>
618
+ ) : (
619
+ <p className="rounded border border-slate-700 bg-slate-900/70 px-2 py-1.5 text-[11px] text-slate-500">
620
+ Create another topic first, then copy this question into it.
621
+ </p>
622
+ )}
623
+ <div className="flex items-center justify-end gap-2">
624
+ <button
625
+ type="button"
626
+ onClick={() => setCopyingToTopicQuestionId(null)}
627
+ disabled={isCopying}
628
+ className="px-2 py-1 rounded text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700 disabled:opacity-50 transition-colors"
629
+ >
630
+ Cancel
631
+ </button>
632
+ <button
633
+ type="button"
634
+ onClick={() =>
635
+ handleCopyQuestion(
636
+ sourceTopicId,
637
+ q.id,
638
+ targetParentId,
639
+ selectedTopicId,
640
+ )
641
+ }
642
+ disabled={isCopying || !selectedTopicId}
643
+ 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"
644
+ >
645
+ {isCopying && <Loader2 className="w-3 h-3 animate-spin" />}
646
+ Copy
647
+ </button>
648
+ </div>
649
+ </div>
650
+ </div>
651
+ );
652
+ };
653
+
360
654
  const renderQuestionRow = (
361
655
  q: Question,
362
656
  topicId: string,
@@ -491,6 +785,8 @@ export default function Sidebar() {
491
785
  <button
492
786
  onClick={() => {
493
787
  setOpenMenuQuestionId(null);
788
+ setCopyingQuestionId(null);
789
+ setCopyingToTopicQuestionId(null);
494
790
  setMovingQuestionId((prev) =>
495
791
  prev === q.id ? null : q.id,
496
792
  );
@@ -502,13 +798,45 @@ export default function Sidebar() {
502
798
  >
503
799
  <ArrowRightLeft className="w-3 h-3" /> Move
504
800
  </button>
505
- <div className="border-t border-slate-700 my-0.5" />
506
801
  <button
507
802
  onClick={() => {
508
803
  setOpenMenuQuestionId(null);
509
- const allQ = questionsByTopic[topicId] ?? [];
804
+ setMovingQuestionId(null);
805
+ setCopyingToTopicQuestionId(null);
806
+ setCopyingQuestionId((prev) =>
807
+ prev === q.id ? null : q.id,
808
+ );
809
+ setCopyTargetParentId(
810
+ q.parentQuestionId ?? ROOT_PARENT_VALUE,
811
+ );
812
+ }}
813
+ 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"
814
+ >
815
+ <Copy className="w-3 h-3" /> Copy in topic
816
+ </button>
817
+ <button
818
+ onClick={() => {
819
+ setOpenMenuQuestionId(null);
820
+ openCopyToTopicPicker(q, topicId);
821
+ }}
822
+ 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"
823
+ >
824
+ <Copy className="w-3 h-3" /> Copy to topic
825
+ </button>
826
+ <div className="border-t border-slate-700 my-0.5" />
827
+ <button
828
+ onClick={async () => {
829
+ setOpenMenuQuestionId(null);
830
+ let allQ = questionsByTopic[topicId] ?? [];
831
+ try {
832
+ allQ = await api.fetchQuestions(topicId);
833
+ } catch {
834
+ // Fall back to the already-loaded sidebar snapshot.
835
+ }
836
+ const latestQuestion =
837
+ allQ.find((candidate) => candidate.id === q.id) ?? q;
510
838
  downloadJson(
511
- buildQuestionExport(q, allQ),
839
+ await buildQuestionExport(latestQuestion, allQ),
512
840
  `${q.title.replace(/[^a-z0-9]+/gi, "-")}.json`,
513
841
  );
514
842
  }}
@@ -618,6 +946,8 @@ export default function Sidebar() {
618
946
  </div>
619
947
  )}
620
948
  {renderMoveQuestionPicker(questions, q, topicId, depth)}
949
+ {renderCopyQuestionPicker(questions, q, topicId, depth)}
950
+ {renderCopyToTopicPicker(q, topicId, depth)}
621
951
  {/* Recurse into children — hidden when collapsed */}
622
952
  {!isCollapsed &&
623
953
  renderQuestionSubtree(questions, topicId, q.id, depth + 1)}
@@ -661,6 +991,54 @@ export default function Sidebar() {
661
991
  downloadBase="/api/workspace/context-files"
662
992
  label="workspace"
663
993
  />
994
+ {activeWs?.driveConfig?.folderId && isAtDriveFolderRoot && (
995
+ <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">
996
+ Select a Drive folder first. Its workspace-files and questions
997
+ sync from inside that selected folder.
998
+ </p>
999
+ )}
1000
+ {canSyncDriveFolder && (
1001
+ <div className="mt-2 space-y-1.5">
1002
+ <p className="text-[10px] text-slate-600">
1003
+ Sync selected folder: {driveSyncTargetName}
1004
+ </p>
1005
+ <div className="grid grid-cols-2 gap-1.5">
1006
+ <button
1007
+ type="button"
1008
+ onClick={handlePushToDrive}
1009
+ disabled={pushing || syncing}
1010
+ 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"
1011
+ title="Push topics, questions, and workspace files into the selected Drive folder"
1012
+ >
1013
+ {pushing ? (
1014
+ <Loader2 className="w-3 h-3 animate-spin" />
1015
+ ) : (
1016
+ <Upload className="w-3 h-3" />
1017
+ )}
1018
+ {pushing ? "Pushing…" : "Push folder"}
1019
+ </button>
1020
+ <button
1021
+ type="button"
1022
+ onClick={handleResync}
1023
+ disabled={syncing || pushing}
1024
+ 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"
1025
+ title="Pull topics, questions, and workspace files from the selected Drive folder"
1026
+ >
1027
+ {syncing ? (
1028
+ <Loader2 className="w-3 h-3 animate-spin" />
1029
+ ) : (
1030
+ <RefreshCw className="w-3 h-3" />
1031
+ )}
1032
+ {syncing ? "Pulling…" : "Pull folder"}
1033
+ </button>
1034
+ </div>
1035
+ {driveFileSyncStatus && (
1036
+ <p className="text-[10px] leading-relaxed text-slate-500">
1037
+ {driveFileSyncStatus}
1038
+ </p>
1039
+ )}
1040
+ </div>
1041
+ )}
664
1042
  </div>
665
1043
  )}
666
1044
  </div>
@@ -888,12 +1266,22 @@ export default function Sidebar() {
888
1266
  <Trash2 className="w-3 h-3" />
889
1267
  </button>
890
1268
  <button
891
- onClick={(e) => {
1269
+ onClick={async (e) => {
892
1270
  e.stopPropagation();
893
- const topicQuestions = questionsByTopic[topic.id] ?? [];
1271
+ let topicQuestions = questionsByTopic[topic.id] ?? [];
1272
+ try {
1273
+ topicQuestions = await api.fetchQuestions(topic.id);
1274
+ } catch {
1275
+ // Fall back to the already-loaded sidebar snapshot.
1276
+ }
894
1277
  const rootQuestions = topicQuestions.filter(
895
1278
  (q) => !q.parentQuestionId,
896
1279
  );
1280
+ const exportedQuestions = await Promise.all(
1281
+ rootQuestions.map((q) =>
1282
+ buildQuestionExport(q, topicQuestions),
1283
+ ),
1284
+ );
897
1285
  downloadJson(
898
1286
  {
899
1287
  id: topic.id,
@@ -901,9 +1289,7 @@ export default function Sidebar() {
901
1289
  systemContext: topic.systemContext ?? "",
902
1290
  contextFiles: topic.contextFiles,
903
1291
  createdAt: topic.createdAt,
904
- questions: rootQuestions.map((q) =>
905
- buildQuestionExport(q, topicQuestions),
906
- ),
1292
+ questions: exportedQuestions,
907
1293
  },
908
1294
  `${topic.name.replace(/[^a-z0-9]+/gi, "-")}.json`,
909
1295
  );
@@ -152,6 +152,10 @@ export default function WorkspaceSwitcher() {
152
152
  setSyncResult(null);
153
153
  try {
154
154
  const result = await syncWorkspace(ws.id);
155
+ if ("needsAuth" in result && result.needsAuth) {
156
+ window.location.href = result.authUrl;
157
+ return;
158
+ }
155
159
  setSyncResult(result);
156
160
  } catch (err: any) {
157
161
  setSyncResult({