create-interview-cockpit 0.3.0 → 0.5.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.
Files changed (28) hide show
  1. package/README.md +23 -0
  2. package/package.json +1 -1
  3. package/template/client/package-lock.json +42 -0
  4. package/template/client/package.json +5 -0
  5. package/template/client/src/App.tsx +45 -12
  6. package/template/client/src/api.ts +174 -0
  7. package/template/client/src/components/AiSettingsModal.tsx +1041 -0
  8. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  9. package/template/client/src/components/ChatMessage.tsx +110 -27
  10. package/template/client/src/components/ChatView.tsx +239 -137
  11. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  12. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  13. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  14. package/template/client/src/components/DocRefModal.tsx +502 -0
  15. package/template/client/src/components/FileAttachments.tsx +109 -9
  16. package/template/client/src/components/FilePickerModal.tsx +181 -0
  17. package/template/client/src/components/FileViewerModal.tsx +406 -28
  18. package/template/client/src/components/MarkdownRenderer.tsx +210 -2
  19. package/template/client/src/components/Sidebar.tsx +213 -125
  20. package/template/client/src/components/TextAnnotator.tsx +8 -15
  21. package/template/client/src/components/VizCraftEmbed.tsx +645 -0
  22. package/template/client/src/store.ts +275 -0
  23. package/template/client/src/types.ts +9 -0
  24. package/template/cockpit.json +1 -1
  25. package/template/data/ai-settings.json +49 -0
  26. package/template/server/src/google-drive.ts +109 -1
  27. package/template/server/src/index.ts +1187 -76
  28. package/template/server/src/storage.ts +359 -2
@@ -1,7 +1,60 @@
1
1
  import { create } from "zustand";
2
2
  import type { Topic, Question, CodeSnippet, WorkspaceMeta } from "./types";
3
+ import type { AiSettings } from "./api";
3
4
  import * as api from "./api";
4
5
 
6
+ const DEFAULT_AI_SETTINGS: AiSettings = {
7
+ systemPrompt: "",
8
+ responseProfiles: {
9
+ concise: { maxOutputTokens: 1000, maxSteps: 3 },
10
+ moderate: { maxOutputTokens: 1000, maxSteps: 5 },
11
+ normal: { maxOutputTokens: 3000, maxSteps: 5 },
12
+ },
13
+ vizGuide: "",
14
+ alwaysSendPrefsDefault: false,
15
+ thinkingBudget: 0,
16
+ promptGroups: {
17
+ length: {
18
+ label: "Response Length",
19
+ description:
20
+ "Appended to the user message when the selected length changes.",
21
+ default: "normal",
22
+ options: {
23
+ concise:
24
+ "Keep the response concise. Aim for roughly 300 characters of text when possible. These limits do not apply to mermaid diagrams. You can generate as many as you want to explain the solution effectively. Prioritize diagrams over text.",
25
+ moderate:
26
+ "Keep the response moderately detailed. Aim for roughly 550 characters of text when possible.",
27
+ normal:
28
+ "Use a fuller answer with enough context to explain the idea clearly.",
29
+ },
30
+ },
31
+ style: {
32
+ label: "Response Style",
33
+ description:
34
+ "Appended to the user message when the selected style changes.",
35
+ default: "prose",
36
+ options: {
37
+ prose:
38
+ "Use natural prose with short paragraphs. Avoid bullet lists and numbered lists unless I explicitly ask for them.",
39
+ bullets: "Use bullet points and short lists as the main format.",
40
+ structured:
41
+ "Use structured sections with headings and numbered steps when helpful.",
42
+ },
43
+ },
44
+ audience: {
45
+ label: "Response Audience",
46
+ description:
47
+ "Appended to the user message when the selected audience changes.",
48
+ default: "normal",
49
+ options: {
50
+ normal: "",
51
+ beginner:
52
+ "When using technical terms or abbreviations, immediately expand their meaning in square brackets right after the term — e.g. 'TCP [Transmission Control Protocol — a connection-oriented transport protocol]' or 'idempotent [an operation that produces the same result no matter how many times it is applied]'. Do this throughout your response so I never need to look anything up.",
53
+ },
54
+ },
55
+ },
56
+ };
57
+
5
58
  interface Store {
6
59
  topics: Topic[];
7
60
  questionsByTopic: Record<string, Question[]>;
@@ -13,6 +66,7 @@ interface Store {
13
66
  showCodePanel: boolean;
14
67
  showSidebar: boolean;
15
68
  viewingFile: string | null;
69
+ viewingDoc: { fileId: string; quote: string; fileName: string } | null;
16
70
 
17
71
  // ── Workspaces ───────────────────────────────────────────────
18
72
  workspaces: WorkspaceMeta[];
@@ -85,26 +139,107 @@ interface Store {
85
139
  files: FileList | File[],
86
140
  ) => Promise<void>;
87
141
  removeTopicFile: (topicId: string, fileId: string) => Promise<void>;
142
+ linkFileToTopic: (
143
+ topicId: string,
144
+ fileId: string,
145
+ originalName: string,
146
+ ) => Promise<void>;
88
147
  uploadQuestionFiles: (
89
148
  questionId: string,
90
149
  files: FileList | File[],
91
150
  ) => Promise<void>;
92
151
  removeQuestionFile: (questionId: string, fileId: string) => Promise<void>;
152
+ linkFileToQuestion: (
153
+ questionId: string,
154
+ fileId: string,
155
+ originalName: string,
156
+ ) => Promise<void>;
157
+ saveCodeSnippetToQuestion: (
158
+ questionId: string,
159
+ code: string,
160
+ language: string,
161
+ label: string,
162
+ origin: "user" | "ai" | "sandbox",
163
+ ) => Promise<import("./types").ContextFile>;
93
164
  clearMessages: (questionId: string) => Promise<void>;
165
+
166
+ // ── Workspace Context Files ──────────────────────────────────
167
+ workspaceFiles: import("./types").ContextFile[];
168
+ fetchWorkspaceFiles: () => Promise<void>;
169
+ uploadWorkspaceFiles: (files: FileList | File[]) => Promise<void>;
170
+ removeWorkspaceFile: (fileId: string) => Promise<void>;
171
+
94
172
  updateQuestionSystemContext: (
95
173
  questionId: string,
96
174
  systemContext: string,
97
175
  ) => Promise<void>;
98
176
  openFileViewer: (path: string) => void;
99
177
  closeFileViewer: () => void;
178
+ openDocViewer: (fileId: string, quote: string, fileName: string) => void;
179
+ closeDocViewer: () => void;
180
+ /** Inline code blocks written by the AI, keyed by `inline:<id>` */
181
+ inlineCodeSnippets: Record<
182
+ string,
183
+ { content: string; language: string; label: string }
184
+ >;
185
+ registerInlineCode: (
186
+ id: string,
187
+ entry: { content: string; language: string; label: string },
188
+ ) => void;
100
189
  codeSnippets: CodeSnippet[];
101
190
  addSnippet: (snippet: CodeSnippet) => void;
102
191
  removeSnippet: (id: string) => void;
103
192
  clearSnippets: () => void;
193
+
194
+ // ── AI Settings ──────────────────────────────────────────────
195
+ aiSettings: AiSettings;
196
+ fetchAiSettings: () => Promise<void>;
197
+ saveAiSettings: (patch: Partial<AiSettings>) => Promise<void>;
198
+ /** The currently active preference suffix (LENGTH/STYLE/AUDIENCE/etc) built by ChatView. */
199
+ livePreferenceSuffix: string;
200
+ setLivePreferenceSuffix: (suffix: string) => void;
201
+ showSettings: boolean;
202
+ openSettings: () => void;
203
+ closeSettings: () => void;
204
+
205
+ // ── Code Runner ──────────────────────────────────────────────
206
+ showCodeRunner: boolean;
207
+ /** Code pre-filled into the runner when opened */
208
+ runnerInitialCode: string;
209
+ /** Language hint — 'typescript' or 'javascript' */
210
+ runnerInitialLanguage: string;
211
+ /** When set, opens the runner in sandbox mode with these values pre-filled */
212
+ runnerInitialSandbox: {
213
+ serverCode: string;
214
+ serverLang: string;
215
+ clientCode: string;
216
+ clientLang: string;
217
+ fileId?: string;
218
+ } | null;
219
+ openCodeRunner: (code?: string, language?: string) => void;
220
+ openSandbox: (
221
+ serverCode: string,
222
+ serverLang: string,
223
+ clientCode: string,
224
+ clientLang: string,
225
+ fileId?: string,
226
+ ) => void;
227
+ overwriteContextFileContent: (
228
+ questionId: string,
229
+ fileId: string,
230
+ content: string,
231
+ ) => Promise<void>;
232
+ renameContextFile: (
233
+ questionId: string,
234
+ fileId: string,
235
+ label: string,
236
+ ) => Promise<void>;
237
+ closeCodeRunner: () => void;
104
238
  }
105
239
 
106
240
  export const useStore = create<Store>((set, get) => ({
107
241
  topics: [],
242
+ workspaceFiles: [],
108
243
  questionsByTopic: {},
109
244
  selectedTopicId: null,
110
245
  selectedQuestionId: null,
@@ -114,7 +249,16 @@ export const useStore = create<Store>((set, get) => ({
114
249
  showCodePanel: false,
115
250
  showSidebar: true,
116
251
  viewingFile: null,
252
+ viewingDoc: null,
253
+ inlineCodeSnippets: {},
117
254
  codeSnippets: [],
255
+ aiSettings: DEFAULT_AI_SETTINGS,
256
+ livePreferenceSuffix: "",
257
+ showSettings: false,
258
+ showCodeRunner: false,
259
+ runnerInitialCode: "",
260
+ runnerInitialLanguage: "typescript",
261
+ runnerInitialSandbox: null,
118
262
 
119
263
  // ── Workspaces ───────────────────────────────────────────────
120
264
  workspaces: [],
@@ -154,6 +298,8 @@ export const useStore = create<Store>((set, get) => ({
154
298
  });
155
299
  const topics = await api.fetchTopics();
156
300
  set({ topics });
301
+ const workspaceFiles = await api.fetchWorkspaceFiles();
302
+ set({ workspaceFiles });
157
303
  },
158
304
 
159
305
  deleteWorkspace: async (id) => {
@@ -445,6 +591,17 @@ export const useStore = create<Store>((set, get) => ({
445
591
  }));
446
592
  },
447
593
 
594
+ linkFileToTopic: async (topicId, fileId, originalName) => {
595
+ const cf = await api.linkFileToTopic(topicId, fileId, originalName);
596
+ set((s) => ({
597
+ topics: s.topics.map((t) =>
598
+ t.id === topicId
599
+ ? { ...t, contextFiles: [...(t.contextFiles || []), cf] }
600
+ : t,
601
+ ),
602
+ }));
603
+ },
604
+
448
605
  uploadQuestionFiles: async (questionId, files) => {
449
606
  const uploaded = await api.uploadQuestionFiles(questionId, files);
450
607
  set((s) => ({
@@ -476,6 +633,45 @@ export const useStore = create<Store>((set, get) => ({
476
633
  }));
477
634
  },
478
635
 
636
+ linkFileToQuestion: async (questionId, fileId, originalName) => {
637
+ const cf = await api.linkFileToQuestion(questionId, fileId, originalName);
638
+ set((s) => ({
639
+ currentQuestion:
640
+ s.currentQuestion?.id === questionId
641
+ ? {
642
+ ...s.currentQuestion,
643
+ contextFiles: [...(s.currentQuestion.contextFiles || []), cf],
644
+ }
645
+ : s.currentQuestion,
646
+ }));
647
+ },
648
+
649
+ saveCodeSnippetToQuestion: async (
650
+ questionId,
651
+ code,
652
+ language,
653
+ label,
654
+ origin,
655
+ ) => {
656
+ const cf = await api.saveCodeSnippet(
657
+ questionId,
658
+ code,
659
+ language,
660
+ label,
661
+ origin,
662
+ );
663
+ set((s) => ({
664
+ currentQuestion:
665
+ s.currentQuestion?.id === questionId
666
+ ? {
667
+ ...s.currentQuestion,
668
+ contextFiles: [...(s.currentQuestion.contextFiles || []), cf],
669
+ }
670
+ : s.currentQuestion,
671
+ }));
672
+ return cf;
673
+ },
674
+
479
675
  clearMessages: async (questionId) => {
480
676
  await api.updateQuestion(questionId, { messages: [] });
481
677
  set((s) => ({
@@ -486,6 +682,23 @@ export const useStore = create<Store>((set, get) => ({
486
682
  }));
487
683
  },
488
684
 
685
+ fetchWorkspaceFiles: async () => {
686
+ const files = await api.fetchWorkspaceFiles();
687
+ set({ workspaceFiles: files });
688
+ },
689
+
690
+ uploadWorkspaceFiles: async (files) => {
691
+ const uploaded = await api.uploadWorkspaceFiles(files);
692
+ set((s) => ({ workspaceFiles: [...s.workspaceFiles, ...uploaded] }));
693
+ },
694
+
695
+ removeWorkspaceFile: async (fileId) => {
696
+ await api.deleteWorkspaceFile(fileId);
697
+ set((s) => ({
698
+ workspaceFiles: s.workspaceFiles.filter((f) => f.id !== fileId),
699
+ }));
700
+ },
701
+
489
702
  updateQuestionSystemContext: async (questionId, systemContext) => {
490
703
  const updated = await api.updateQuestion(questionId, { systemContext });
491
704
  set((s) => ({
@@ -507,4 +720,66 @@ export const useStore = create<Store>((set, get) => ({
507
720
 
508
721
  openFileViewer: (path) => set({ viewingFile: path }),
509
722
  closeFileViewer: () => set({ viewingFile: null }),
723
+ registerInlineCode: (id, entry) =>
724
+ set((s) => ({
725
+ inlineCodeSnippets: { ...s.inlineCodeSnippets, [id]: entry },
726
+ })),
727
+ openDocViewer: (fileId, quote, fileName) =>
728
+ set({ viewingDoc: { fileId, quote, fileName } }),
729
+ closeDocViewer: () => set({ viewingDoc: null }),
730
+ openCodeRunner: (code = "", language = "typescript") =>
731
+ set({
732
+ showCodeRunner: true,
733
+ runnerInitialCode: code,
734
+ runnerInitialLanguage: language,
735
+ runnerInitialSandbox: null,
736
+ }),
737
+ openSandbox: (serverCode, serverLang, clientCode, clientLang, fileId?) =>
738
+ set({
739
+ showCodeRunner: true,
740
+ runnerInitialCode: "",
741
+ runnerInitialLanguage: "typescript",
742
+ runnerInitialSandbox: {
743
+ serverCode,
744
+ serverLang,
745
+ clientCode,
746
+ clientLang,
747
+ fileId,
748
+ },
749
+ }),
750
+
751
+ overwriteContextFileContent: async (questionId, fileId, content) => {
752
+ await api.overwriteContextFileContent(questionId, fileId, content);
753
+ },
754
+
755
+ renameContextFile: async (questionId, fileId, label) => {
756
+ const cf = await api.renameContextFile(questionId, fileId, label);
757
+ set((s) => ({
758
+ currentQuestion:
759
+ s.currentQuestion?.id === questionId
760
+ ? {
761
+ ...s.currentQuestion,
762
+ contextFiles: (s.currentQuestion.contextFiles || []).map((f) =>
763
+ f.id === fileId ? { ...f, label: cf.label, name: cf.name } : f,
764
+ ),
765
+ }
766
+ : s.currentQuestion,
767
+ }));
768
+ },
769
+ closeCodeRunner: () => set({ showCodeRunner: false }),
770
+
771
+ fetchAiSettings: async () => {
772
+ const settings = await api.fetchAiSettings();
773
+ set({ aiSettings: settings });
774
+ },
775
+
776
+ saveAiSettings: async (patch) => {
777
+ const updated = await api.saveAiSettings(patch);
778
+ set({ aiSettings: updated });
779
+ },
780
+
781
+ setLivePreferenceSuffix: (suffix) => set({ livePreferenceSuffix: suffix }),
782
+
783
+ openSettings: () => set({ showSettings: true }),
784
+ closeSettings: () => set({ showSettings: false }),
510
785
  }));
@@ -4,6 +4,14 @@ export interface ContextFile {
4
4
  originalName: string;
5
5
  driveFileId?: string;
6
6
  createdAt: string;
7
+ /** Distinguishes how this file was added. 'upload' = user-uploaded doc,
8
+ * 'user' = code saved from Code Runner, 'ai' = AI-generated code block,
9
+ * 'sandbox' = paired server+client sandbox saved as JSON. */
10
+ origin?: "user" | "ai" | "upload" | "sandbox";
11
+ /** Language hint for code snippets (e.g. 'typescript', 'javascript'). */
12
+ language?: string;
13
+ /** Short display label for code snippets. */
14
+ label?: string;
7
15
  }
8
16
 
9
17
  export interface WorkspaceMeta {
@@ -36,6 +44,7 @@ export interface Message {
36
44
  id: string;
37
45
  role: "user" | "assistant";
38
46
  content: string;
47
+ parts?: { type: string; [key: string]: any }[];
39
48
  createdAt?: string;
40
49
  }
41
50
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.2.0"
2
+ "version": "0.4.0"
3
3
  }
@@ -0,0 +1,49 @@
1
+ {
2
+ "systemPrompt": "You are a senior engineering interview coach.\n\nHighest priority: follow the user's explicit response preferences and current conversation context. If they conflict with your default teaching behavior, the user's preference wins.\nExplain clearly, accurately, and practically.\nOnly include Mermaid diagrams, code blocks, or tables when the user explicitly asks for them or when they materially improve the answer.\nIf you show code, use a fenced code block with the correct language.\n\nMermaid syntax rules (follow strictly):\n- Wrap node labels in quotes when they contain special characters: A[\"Microservice A (Producer)\"]\n- Edge labels use |text| syntax: A -->|sends message| B\n- Never put parentheses or brackets inside [] without quoting the label\n- Use simple node IDs (letters/numbers) and put descriptive text in the label\n\nYou can produce animated diagrams using ```viz blocks for flows, step-through walkthroughs, or any explanation that benefits from animation. Call getVizGuide() first to get the full spec reference before writing a viz block.",
3
+ "responseProfiles": {
4
+ "concise": {
5
+ "maxOutputTokens": 1000,
6
+ "maxSteps": 3
7
+ },
8
+ "moderate": {
9
+ "maxOutputTokens": 1000,
10
+ "maxSteps": 5
11
+ },
12
+ "normal": {
13
+ "maxOutputTokens": 3000,
14
+ "maxSteps": 5
15
+ }
16
+ },
17
+ "vizGuide": "Animated viz diagrams — full spec reference\n\nThe content of a ```viz block is a YAML (preferred) or JSON object:\n\n view: { width: 900, height: 360 } # required — scene canvas size\n nodes: # required — list of nodes\n - id: client # required, kebab-case\n label: Client # display text\n x: 80 # required — absolute X centre in view\n y: 180 # required — absolute Y centre in view\n shape: rect # rect (default) | circle | cylinder | diamond | hexagon | ellipse | cloud | document | note\n width: 120 # optional, default 120\n height: 40 # optional, default 40\n fill: '#0e7490' # optional hex colour\n stroke: '#164e63' # optional\n edges: # optional\n - from: client\n to: lb\n label: HTTP Request # optional\n animate: flow # optional — adds marching-ants stroke animation\n style: straight # straight (default) | curved | orthogonal\n autoSignals: # optional — self-animating signal dots, no code needed\n - id: req-flow # unique key\n chain: [client, lb, server] # ordered node ids — dot travels this path\n loop: true # restart after reaching the end\n durationPerHop: 700 # ms per hop, default 800\n loopDelay: 0 # ms pause before restart\n color: '#7dd3fc' # optional dot colour\n steps: # optional — replaces autoSignals; adds step-through UI\n - label: Client sends a request # shown in step indicator\n highlight: [client, lb] # node ids to highlight on this step\n signals: # one-shot signals that play on this step\n - id: s1\n chain: [client, lb]\n durationPerHop: 800\n autoAdvance: false # true = auto-proceeds after signals complete\n\nRules:\n- x/y are ABSOLUTE coordinates within the view. Plan layout so nodes don't overlap (typical node is 120×40).\n- Use autoSignals (loop: true) for animated overview diagrams. Use steps for walkthroughs where you explain each phase.\n- Do NOT mix autoSignals and steps in the same spec.\n- Keep node ids simple, lowercase, no spaces (use hyphens).\n- Prefer YAML — it is more readable. Only use JSON if the structure is trivial.\n\nMinimal animated example (autoSignal loop):\n```viz\nview: { width: 860, height: 260 }\nnodes:\n - { id: client, label: Client, x: 80, y: 130, width: 120, height: 40, fill: '#0e7490' }\n - { id: lb, label: Load Balancer, x: 430, y: 130, width: 160, height: 40, fill: '#7c3aed' }\n - { id: srv, label: Server, x: 770, y: 130, width: 120, height: 40, fill: '#065f46' }\nedges:\n - { from: client, to: lb }\n - { from: lb, to: srv }\nautoSignals:\n - { id: flow, chain: [client, lb, srv], loop: true, durationPerHop: 700 }\n```\n\nMinimal step-through example:\n```viz\nview: { width: 860, height: 260 }\nnodes:\n - { id: client, label: Client, x: 80, y: 130, width: 120, height: 40, fill: '#0e7490' }\n - { id: cache, label: Cache, x: 430, y: 130, width: 120, height: 40, fill: '#7c3aed' }\n - { id: db, label: Database, x: 770, y: 130, width: 120, height: 40, fill: '#065f46' }\nedges:\n - { from: client, to: cache }\n - { from: cache, to: db }\nsteps:\n - label: Client queries the cache\n highlight: [client, cache]\n signals: [{ id: s1, chain: [client, cache], durationPerHop: 800 }]\n - label: Cache miss — forward to DB\n highlight: [cache, db]\n signals: [{ id: s2, chain: [cache, db], durationPerHop: 800 }]\n - label: DB responds, cache is populated\n highlight: [db, cache, client]\n signals:\n - { id: s3a, chain: [db, cache], durationPerHop: 600 }\n - { id: s3b, chain: [cache, client], durationPerHop: 600 }\n```",
18
+ "promptGroups": {
19
+ "length": {
20
+ "label": "Response Length",
21
+ "description": "Appended to the user message when the selected length changes.",
22
+ "default": "normal",
23
+ "options": {
24
+ "concise": "Keep the response very concise",
25
+ "moderate": "Keep the response moderately detailed.",
26
+ "normal": "Use a fuller answer with enough context to explain the idea clearly."
27
+ }
28
+ },
29
+ "style": {
30
+ "label": "Response Style",
31
+ "description": "Appended to the user message when the selected style changes.",
32
+ "default": "structured",
33
+ "options": {
34
+ "prose": "Use natural prose with short paragraphs.",
35
+ "bullets": "Use bullet points and short lists as the main format.",
36
+ "structured": "Use structured sections with headings and numbered steps when helpful."
37
+ }
38
+ },
39
+ "audience": {
40
+ "label": "Response Audience",
41
+ "description": "Appended to the user message when the selected audience changes.",
42
+ "default": "normal",
43
+ "options": {
44
+ "normal": "",
45
+ "beginner": "When using technical terms or abbreviations, immediately expand their meaning in square brackets."
46
+ }
47
+ }
48
+ }
49
+ }
@@ -313,6 +313,14 @@ export async function syncWorkspace(
313
313
  const questions = pending.filter((p) => !p.isContextFile);
314
314
  const contextFiles = pending.filter((p) => p.isContextFile);
315
315
 
316
+ // Collect parent-title links to re-apply after all questions are saved
317
+ // (IDs are regenerated on import, so we must link by title within a topic)
318
+ const parentLinks: Array<{
319
+ questionId: string;
320
+ topicId: string;
321
+ parentTitle: string;
322
+ }> = [];
323
+
316
324
  await Promise.all(
317
325
  questions.map(async ({ topicId, filename, buffer }) => {
318
326
  try {
@@ -320,6 +328,9 @@ export async function syncWorkspace(
320
328
  let systemContext = "";
321
329
  let messages: storage.Question["messages"] = [];
322
330
  let codeContextFiles: string[] = [];
331
+ let codeAnnotations: storage.Question["codeAnnotations"] = {};
332
+ let parentTitle: string | null = null;
333
+ const restoredContextFiles: storage.ContextFile[] = [];
323
334
 
324
335
  if (filename.endsWith(".json")) {
325
336
  // Our own exported JSON — restore fields directly, no text extraction
@@ -329,6 +340,42 @@ export async function syncWorkspace(
329
340
  systemContext = parsed.systemContext || "";
330
341
  messages = parsed.messages || [];
331
342
  codeContextFiles = parsed.codeContextFiles || [];
343
+ codeAnnotations = parsed.codeAnnotations || {};
344
+ parentTitle = parsed.parentTitle || null;
345
+
346
+ // Restore code snippet context files (user/ai origin)
347
+ if (
348
+ Array.isArray(parsed.codeSnippets) &&
349
+ parsed.codeSnippets.length > 0
350
+ ) {
351
+ const ctxDir =
352
+ storage.getContextFilesDirForWorkspace(workspaceId);
353
+ await Promise.all(
354
+ parsed.codeSnippets.map(
355
+ async (cs: storage.ContextFile & { content?: string }) => {
356
+ if (
357
+ cs.content &&
358
+ (cs.origin === "user" ||
359
+ cs.origin === "ai" ||
360
+ cs.origin === "sandbox")
361
+ ) {
362
+ try {
363
+ await fs.mkdir(ctxDir, { recursive: true });
364
+ await fs.writeFile(
365
+ path.join(ctxDir, cs.id),
366
+ Buffer.from(cs.content, "utf-8"),
367
+ );
368
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
369
+ const { content: _content, ...cfMeta } = cs;
370
+ restoredContextFiles.push(cfMeta);
371
+ } catch {
372
+ /* skip bad blobs */
373
+ }
374
+ }
375
+ },
376
+ ),
377
+ );
378
+ }
332
379
  } catch {
333
380
  // Malformed JSON — fall through to raw text
334
381
  systemContext = buffer.toString("utf-8");
@@ -344,18 +391,47 @@ export async function syncWorkspace(
344
391
  title,
345
392
  systemContext,
346
393
  codeContextFiles,
347
- contextFiles: [],
394
+ contextFiles: restoredContextFiles,
348
395
  messages,
396
+ codeAnnotations,
349
397
  createdAt: new Date().toISOString(),
350
398
  };
351
399
  await storage.saveQuestion(q);
352
400
  result.filesImported++;
401
+ if (parentTitle) {
402
+ parentLinks.push({ questionId: q.id, topicId, parentTitle });
403
+ }
353
404
  } catch (err: any) {
354
405
  result.errors.push(`${filename}: ${err?.message ?? "failed"}`);
355
406
  }
356
407
  }),
357
408
  );
358
409
 
410
+ // ── Phase 4b: re-link parent–child relationships by title ───────────────────
411
+ // IDs are regenerated on import, so we match parent by title within the same topic.
412
+ if (parentLinks.length > 0) {
413
+ // Group by topicId to avoid redundant getQuestionsByTopic calls
414
+ const byTopic = new Map<string, typeof parentLinks>();
415
+ for (const link of parentLinks) {
416
+ const arr = byTopic.get(link.topicId) ?? [];
417
+ arr.push(link);
418
+ byTopic.set(link.topicId, arr);
419
+ }
420
+ for (const [topicId, links] of byTopic) {
421
+ const allInTopic = await storage.getQuestionsByTopic(topicId);
422
+ for (const { questionId, parentTitle } of links) {
423
+ const parent = allInTopic.find(
424
+ (q) => q.title === parentTitle && q.id !== questionId,
425
+ );
426
+ const child = allInTopic.find((q) => q.id === questionId);
427
+ if (parent && child) {
428
+ child.parentQuestionId = parent.id;
429
+ await storage.saveQuestion(child);
430
+ }
431
+ }
432
+ }
433
+ }
434
+
359
435
  // ── Phase 5: save context file blobs in parallel, then update topics.json once ─
360
436
  if (contextFiles.length > 0) {
361
437
  // Write binary files in parallel
@@ -645,13 +721,45 @@ export async function exportWorkspace(
645
721
  try {
646
722
  const safeName =
647
723
  q.title.replace(/[/\\:*?"<>|]/g, "-").trim() || q.id;
724
+ // Look up parent title so the relationship can be restored on import
725
+ const parentTitle = q.parentQuestionId
726
+ ? questions.find((p) => p.id === q.parentQuestionId)?.title
727
+ : undefined;
728
+
729
+ // Read code snippet blobs so they survive drive sync
730
+ const ctxDir =
731
+ storage.getContextFilesDirForWorkspace(workspaceId);
732
+ const contextFilesWithContent: Array<
733
+ storage.ContextFile & { content?: string }
734
+ > = [];
735
+ for (const cf of q.contextFiles || []) {
736
+ if (
737
+ cf.origin === "user" ||
738
+ cf.origin === "ai" ||
739
+ cf.origin === "sandbox"
740
+ ) {
741
+ try {
742
+ const content = await fs.readFile(
743
+ path.join(ctxDir, cf.id),
744
+ "utf-8",
745
+ );
746
+ contextFilesWithContent.push({ ...cf, content });
747
+ } catch {
748
+ contextFilesWithContent.push(cf);
749
+ }
750
+ }
751
+ }
752
+
648
753
  // Export full question as JSON so nothing is lost on sync-back
649
754
  const payload = JSON.stringify(
650
755
  {
651
756
  title: q.title,
757
+ parentTitle: parentTitle ?? null,
652
758
  systemContext: q.systemContext || "",
653
759
  messages: q.messages,
654
760
  codeContextFiles: q.codeContextFiles,
761
+ codeAnnotations: q.codeAnnotations ?? {},
762
+ codeSnippets: contextFilesWithContent,
655
763
  },
656
764
  null,
657
765
  2,