create-interview-cockpit 0.4.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 (27) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +19 -0
  3. package/template/client/package.json +3 -0
  4. package/template/client/src/App.tsx +17 -0
  5. package/template/client/src/api.ts +135 -0
  6. package/template/client/src/components/AiSettingsModal.tsx +218 -4
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +110 -27
  9. package/template/client/src/components/ChatView.tsx +69 -4
  10. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  13. package/template/client/src/components/DocRefModal.tsx +502 -0
  14. package/template/client/src/components/FileAttachments.tsx +109 -9
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/MarkdownRenderer.tsx +205 -2
  18. package/template/client/src/components/Sidebar.tsx +213 -127
  19. package/template/client/src/components/TextAnnotator.tsx +8 -15
  20. package/template/client/src/components/VizCraftEmbed.tsx +162 -19
  21. package/template/client/src/store.ts +201 -0
  22. package/template/client/src/types.ts +8 -0
  23. package/template/cockpit.json +1 -1
  24. package/template/package.json +1 -1
  25. package/template/server/src/google-drive.ts +109 -1
  26. package/template/server/src/index.ts +1107 -46
  27. package/template/server/src/storage.ts +263 -2
@@ -12,9 +12,8 @@ interface Props {
12
12
  onAnnotationUpdate: (updated: Annotation) => void;
13
13
  bookmarkedBlockIndex?: number;
14
14
  onBookmarkBlock?: (blockIndex: number) => void;
15
- responseLength?: string;
16
- responseStyle?: string;
17
- responseAudience?: string;
15
+ preferenceSuffix?: string;
16
+ onSpecRefined?: (originalSpec: string, newSpec: string) => void;
18
17
  }
19
18
 
20
19
  type Phase = "idle" | "button" | "input" | "loading";
@@ -119,9 +118,8 @@ export default function TextAnnotator({
119
118
  onAnnotationUpdate,
120
119
  bookmarkedBlockIndex,
121
120
  onBookmarkBlock,
122
- responseLength,
123
- responseStyle,
124
- responseAudience,
121
+ preferenceSuffix,
122
+ onSpecRefined,
125
123
  }: Props) {
126
124
  const containerRef = useRef<HTMLDivElement>(null);
127
125
  const annotationsRef = useRef(annotations);
@@ -207,9 +205,7 @@ export default function TextAnnotator({
207
205
  selectedText,
208
206
  prompt: inputValue.trim(),
209
207
  messageContent: content,
210
- responseLength,
211
- responseStyle,
212
- responseAudience,
208
+ preferenceSuffix,
213
209
  }),
214
210
  });
215
211
  const data = await res.json();
@@ -248,9 +244,7 @@ export default function TextAnnotator({
248
244
  messageContent: content,
249
245
  priorResponse: annotation.response,
250
246
  followUps: annotation.followUps ?? [],
251
- responseLength,
252
- responseStyle,
253
- responseAudience,
247
+ preferenceSuffix,
254
248
  }),
255
249
  });
256
250
  const data = await res.json();
@@ -288,6 +282,7 @@ export default function TextAnnotator({
288
282
  onAnnotationClick={handleAnnotationClick}
289
283
  bookmarkedBlockIndex={bookmarkedBlockIndex}
290
284
  onBookmarkBlock={onBookmarkBlock}
285
+ onSpecRefined={onSpecRefined}
291
286
  />
292
287
 
293
288
  {/* Annotation dialog — opened by clicking an underlined annotation link */}
@@ -298,9 +293,7 @@ export default function TextAnnotator({
298
293
  onUpdate={onAnnotationUpdate}
299
294
  messageContent={content}
300
295
  initialPos={dialogPos}
301
- responseLength={responseLength}
302
- responseStyle={responseStyle}
303
- responseAudience={responseAudience}
296
+ preferenceSuffix={preferenceSuffix}
304
297
  />
305
298
  )}
306
299
 
@@ -8,6 +8,9 @@ import {
8
8
  Loader2,
9
9
  ZoomIn,
10
10
  ZoomOut,
11
+ Send,
12
+ MessageSquare,
13
+ Undo2,
11
14
  } from "lucide-react";
12
15
  import { parse as parseYaml } from "yaml";
13
16
  import {
@@ -86,8 +89,24 @@ function ensureVizCss() {
86
89
  document.head.appendChild(style);
87
90
  }
88
91
 
92
+ /**
93
+ * Replace typographic characters that the YAML parser mishandles:
94
+ * - en-dash (U+2013) and em-dash (U+2014) → plain hyphen
95
+ * - left/right curly double-quotes → straight double-quote
96
+ * - left/right curly single-quotes / apostrophes → straight single-quote
97
+ *
98
+ * These are commonly injected by LLMs or copy-paste from rich text.
99
+ */
100
+ function sanitizeSpecText(raw: string): string {
101
+ return raw
102
+ .replace(/\u2013/g, "-") // en-dash –
103
+ .replace(/\u2014/g, "-") // em-dash —
104
+ .replace(/[\u201C\u201D]/g, '"') // curly double quotes " "
105
+ .replace(/[\u2018\u2019\u02BC]/g, "'"); // curly single quotes ' '
106
+ }
107
+
89
108
  function parseSpec(raw: string): VizSpec {
90
- const trimmed = raw.trim();
109
+ const trimmed = sanitizeSpecText(raw.trim());
91
110
  // Try JSON first, then YAML
92
111
  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
93
112
  return JSON.parse(trimmed) as VizSpec;
@@ -139,9 +158,11 @@ interface StepState {
139
158
 
140
159
  interface Props {
141
160
  spec: string;
161
+ /** Called after the user successfully refines the spec so the parent can persist it. */
162
+ onSpecRefined?: (originalSpec: string, newSpec: string) => void;
142
163
  }
143
164
 
144
- export default memo(function VizCraftEmbed({ spec }: Props) {
165
+ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
145
166
  const containerRef = useRef<HTMLDivElement>(null);
146
167
  const controllerRef = useRef<MountController | StepController | null>(null);
147
168
  // Tracks the active step's MountController (set in custom step mode) so panZoom is accessible
@@ -150,10 +171,16 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
150
171
  const [error, setError] = useState<string | null>(null);
151
172
  const [fixing, setFixing] = useState(false);
152
173
  const [stepState, setStepState] = useState<StepState | null>(null);
174
+ const [refineInput, setRefineInput] = useState("");
175
+ const [refining, setRefining] = useState(false);
176
+ const [refineHistory, setRefineHistory] = useState<
177
+ Array<{ prompt: string; spec: string }>
178
+ >([]);
153
179
 
154
180
  // Keep activeSpec in sync when the prop changes (streaming / message reload)
155
181
  useEffect(() => {
156
182
  setActiveSpec(spec);
183
+ setRefineHistory([]);
157
184
  }, [spec]);
158
185
 
159
186
  const handleFix = async () => {
@@ -174,6 +201,41 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
174
201
  }
175
202
  };
176
203
 
204
+ const handleRefine = async (e: React.FormEvent) => {
205
+ e.preventDefault();
206
+ const prompt = refineInput.trim();
207
+ if (!prompt || refining) return;
208
+ setRefining(true);
209
+ try {
210
+ const res = await fetch("/api/refine-viz", {
211
+ method: "POST",
212
+ headers: { "Content-Type": "application/json" },
213
+ body: JSON.stringify({
214
+ spec: activeSpec,
215
+ prompt,
216
+ history: refineHistory,
217
+ }),
218
+ });
219
+ if (!res.ok) throw new Error("Refine request failed");
220
+ const { spec: refined } = (await res.json()) as { spec: string };
221
+ setRefineHistory((prev) => [...prev, { prompt, spec: activeSpec }]);
222
+ onSpecRefined?.(spec, refined);
223
+ setActiveSpec(refined);
224
+ setRefineInput("");
225
+ } catch (err) {
226
+ console.error("Refine viz error:", err);
227
+ } finally {
228
+ setRefining(false);
229
+ }
230
+ };
231
+
232
+ const handleUndoRefine = () => {
233
+ if (refineHistory.length === 0) return;
234
+ const prev = refineHistory[refineHistory.length - 1];
235
+ setActiveSpec(prev.spec);
236
+ setRefineHistory((h) => h.slice(0, -1));
237
+ };
238
+
177
239
  // Prevent the parent chat scroll container from scrolling when the user
178
240
  // wheels over the viz. We need a non-passive listener so preventDefault works.
179
241
  // (React's synthetic onWheel is passive in some environments and can't prevent scroll.)
@@ -245,24 +307,43 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
245
307
  };
246
308
  };
247
309
 
310
+ // Timer to unblock "Next" if signals never complete (e.g. invalid edge direction)
311
+ let signalReadyTimer: ReturnType<typeof setTimeout> | null = null;
312
+ const clearSignalTimer = () => {
313
+ if (signalReadyTimer !== null) {
314
+ clearTimeout(signalReadyTimer);
315
+ signalReadyTimer = null;
316
+ }
317
+ };
318
+
248
319
  const goTo = (index: number) => {
249
320
  if (cancelled || index < 0 || index >= total) return;
321
+ clearSignalTimer();
250
322
  // Preserve the current viewport so navigating steps doesn't reset zoom/pan.
251
323
  // getState() is only available after the first mount (prevState is undefined on step 0).
252
324
  const prevState = mountControllerRef.current?.panZoom?.getState();
253
325
  currentMount?.destroy();
254
326
  currentMount = null;
255
327
 
256
- const builder = fromSpec(buildStepSpec(index));
257
- injectLabelMaxWidth(builder);
258
- currentMount = builder.mount(container, {
259
- panZoom: true,
260
- // Restore previous zoom if the user had already panned/zoomed; otherwise fit.
261
- initialZoom: prevState?.zoom ?? "fit",
262
- initialPan: prevState?.pan,
263
- minZoom: 0.1,
264
- maxZoom: 8,
265
- });
328
+ // Wrap VizCraft mount in try-catch — runtime errors (e.g. unknown node in chain)
329
+ // are displayed in the error panel rather than silently freezing the component.
330
+ try {
331
+ const builder = fromSpec(buildStepSpec(index));
332
+ injectLabelMaxWidth(builder);
333
+ currentMount = builder.mount(container, {
334
+ panZoom: true,
335
+ // Restore previous zoom if the user had already panned/zoomed; otherwise fit.
336
+ initialZoom: prevState?.zoom ?? "fit",
337
+ initialPan: prevState?.pan,
338
+ minZoom: 0.1,
339
+ maxZoom: 8,
340
+ });
341
+ } catch (e) {
342
+ setError(
343
+ e instanceof Error ? e.message : "Failed to render step",
344
+ );
345
+ return;
346
+ }
266
347
  mountControllerRef.current = currentMount;
267
348
  currentIndex = index;
268
349
 
@@ -281,15 +362,23 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
281
362
  if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
282
363
  } else {
283
364
  let done = 0;
365
+ const onComplete = () => {
366
+ done++;
367
+ if (done >= signals.length) {
368
+ clearSignalTimer();
369
+ setStepState((prev) => prev && { ...prev, isReady: true });
370
+ if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
371
+ }
372
+ };
284
373
  signals.forEach((s) => {
285
- currentMount!.onSignalComplete(s.id, () => {
286
- done++;
287
- if (done >= signals.length) {
288
- setStepState((prev) => prev && { ...prev, isReady: true });
289
- if (step.autoAdvance) setTimeout(() => goTo(index + 1), 50);
290
- }
291
- });
374
+ currentMount!.onSignalComplete(s.id, onComplete);
292
375
  });
376
+ // Fallback: if signals never fire (e.g. signal chain travels against edge direction),
377
+ // unblock the Next button after 6 s so the user isn't permanently stuck.
378
+ signalReadyTimer = setTimeout(() => {
379
+ signalReadyTimer = null;
380
+ setStepState((prev) => prev && { ...prev, isReady: true });
381
+ }, 6000);
293
382
  }
294
383
  };
295
384
 
@@ -302,6 +391,7 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
302
391
  reset: () => goTo(0),
303
392
  destroy: () => {
304
393
  cancelled = true;
394
+ clearSignalTimer();
305
395
  currentMount?.destroy();
306
396
  currentMount = null;
307
397
  mountControllerRef.current = null;
@@ -497,6 +587,59 @@ export default memo(function VizCraftEmbed({ spec }: Props) {
497
587
  )}
498
588
  </div>
499
589
  )}
590
+
591
+ {/* Refine panel */}
592
+ <div className="border-t border-slate-700/50">
593
+ {/* History chips */}
594
+ {refineHistory.length > 0 && (
595
+ <div className="flex flex-wrap items-center gap-1.5 px-3 pt-2">
596
+ {refineHistory.map((h, i) => (
597
+ <span
598
+ key={i}
599
+ className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-slate-800 text-slate-400 border border-slate-700 max-w-[180px]"
600
+ title={h.prompt}
601
+ >
602
+ <MessageSquare className="w-2.5 h-2.5 flex-shrink-0" />
603
+ <span className="truncate">{h.prompt}</span>
604
+ </span>
605
+ ))}
606
+ <button
607
+ onClick={handleUndoRefine}
608
+ className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-slate-800 text-slate-400 hover:text-amber-300 hover:border-amber-500/40 border border-slate-700 transition-colors"
609
+ title="Undo last refinement"
610
+ >
611
+ <Undo2 className="w-2.5 h-2.5" />
612
+ Undo
613
+ </button>
614
+ </div>
615
+ )}
616
+ {/* Prompt input row */}
617
+ <form
618
+ onSubmit={handleRefine}
619
+ className="flex items-center gap-2 px-3 py-2"
620
+ >
621
+ <input
622
+ type="text"
623
+ value={refineInput}
624
+ onChange={(e) => setRefineInput(e.target.value)}
625
+ placeholder="Describe a change… e.g. add a database node"
626
+ className="flex-1 bg-slate-800 border border-slate-700 rounded px-2.5 py-1.5 text-xs text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-600 transition-colors"
627
+ disabled={refining}
628
+ />
629
+ <button
630
+ type="submit"
631
+ disabled={refining || !refineInput.trim()}
632
+ className="flex items-center justify-center w-7 h-7 rounded bg-cyan-700 text-white hover:bg-cyan-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"
633
+ title="Refine diagram"
634
+ >
635
+ {refining ? (
636
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
637
+ ) : (
638
+ <Send className="w-3.5 h-3.5" />
639
+ )}
640
+ </button>
641
+ </form>
642
+ </div>
500
643
  </div>
501
644
  );
502
645
  });
@@ -11,6 +11,8 @@ const DEFAULT_AI_SETTINGS: AiSettings = {
11
11
  normal: { maxOutputTokens: 3000, maxSteps: 5 },
12
12
  },
13
13
  vizGuide: "",
14
+ alwaysSendPrefsDefault: false,
15
+ thinkingBudget: 0,
14
16
  promptGroups: {
15
17
  length: {
16
18
  label: "Response Length",
@@ -64,6 +66,7 @@ interface Store {
64
66
  showCodePanel: boolean;
65
67
  showSidebar: boolean;
66
68
  viewingFile: string | null;
69
+ viewingDoc: { fileId: string; quote: string; fileName: string } | null;
67
70
 
68
71
  // ── Workspaces ───────────────────────────────────────────────
69
72
  workspaces: WorkspaceMeta[];
@@ -136,18 +139,53 @@ interface Store {
136
139
  files: FileList | File[],
137
140
  ) => Promise<void>;
138
141
  removeTopicFile: (topicId: string, fileId: string) => Promise<void>;
142
+ linkFileToTopic: (
143
+ topicId: string,
144
+ fileId: string,
145
+ originalName: string,
146
+ ) => Promise<void>;
139
147
  uploadQuestionFiles: (
140
148
  questionId: string,
141
149
  files: FileList | File[],
142
150
  ) => Promise<void>;
143
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>;
144
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
+
145
172
  updateQuestionSystemContext: (
146
173
  questionId: string,
147
174
  systemContext: string,
148
175
  ) => Promise<void>;
149
176
  openFileViewer: (path: string) => void;
150
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;
151
189
  codeSnippets: CodeSnippet[];
152
190
  addSnippet: (snippet: CodeSnippet) => void;
153
191
  removeSnippet: (id: string) => void;
@@ -157,13 +195,51 @@ interface Store {
157
195
  aiSettings: AiSettings;
158
196
  fetchAiSettings: () => Promise<void>;
159
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;
160
201
  showSettings: boolean;
161
202
  openSettings: () => void;
162
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;
163
238
  }
164
239
 
165
240
  export const useStore = create<Store>((set, get) => ({
166
241
  topics: [],
242
+ workspaceFiles: [],
167
243
  questionsByTopic: {},
168
244
  selectedTopicId: null,
169
245
  selectedQuestionId: null,
@@ -173,9 +249,16 @@ export const useStore = create<Store>((set, get) => ({
173
249
  showCodePanel: false,
174
250
  showSidebar: true,
175
251
  viewingFile: null,
252
+ viewingDoc: null,
253
+ inlineCodeSnippets: {},
176
254
  codeSnippets: [],
177
255
  aiSettings: DEFAULT_AI_SETTINGS,
256
+ livePreferenceSuffix: "",
178
257
  showSettings: false,
258
+ showCodeRunner: false,
259
+ runnerInitialCode: "",
260
+ runnerInitialLanguage: "typescript",
261
+ runnerInitialSandbox: null,
179
262
 
180
263
  // ── Workspaces ───────────────────────────────────────────────
181
264
  workspaces: [],
@@ -215,6 +298,8 @@ export const useStore = create<Store>((set, get) => ({
215
298
  });
216
299
  const topics = await api.fetchTopics();
217
300
  set({ topics });
301
+ const workspaceFiles = await api.fetchWorkspaceFiles();
302
+ set({ workspaceFiles });
218
303
  },
219
304
 
220
305
  deleteWorkspace: async (id) => {
@@ -506,6 +591,17 @@ export const useStore = create<Store>((set, get) => ({
506
591
  }));
507
592
  },
508
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
+
509
605
  uploadQuestionFiles: async (questionId, files) => {
510
606
  const uploaded = await api.uploadQuestionFiles(questionId, files);
511
607
  set((s) => ({
@@ -537,6 +633,45 @@ export const useStore = create<Store>((set, get) => ({
537
633
  }));
538
634
  },
539
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
+
540
675
  clearMessages: async (questionId) => {
541
676
  await api.updateQuestion(questionId, { messages: [] });
542
677
  set((s) => ({
@@ -547,6 +682,23 @@ export const useStore = create<Store>((set, get) => ({
547
682
  }));
548
683
  },
549
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
+
550
702
  updateQuestionSystemContext: async (questionId, systemContext) => {
551
703
  const updated = await api.updateQuestion(questionId, { systemContext });
552
704
  set((s) => ({
@@ -568,6 +720,53 @@ export const useStore = create<Store>((set, get) => ({
568
720
 
569
721
  openFileViewer: (path) => set({ viewingFile: path }),
570
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 }),
571
770
 
572
771
  fetchAiSettings: async () => {
573
772
  const settings = await api.fetchAiSettings();
@@ -579,6 +778,8 @@ export const useStore = create<Store>((set, get) => ({
579
778
  set({ aiSettings: updated });
580
779
  },
581
780
 
781
+ setLivePreferenceSuffix: (suffix) => set({ livePreferenceSuffix: suffix }),
782
+
582
783
  openSettings: () => set({ showSettings: true }),
583
784
  closeSettings: () => set({ showSettings: false }),
584
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 {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.3.0"
2
+ "version": "0.4.0"
3
3
  }
@@ -6,7 +6,7 @@
6
6
  "dev": "concurrently -n server,client -c blue,green \"npm run dev --prefix server\" \"npm run dev --prefix client\"",
7
7
  "build": "npm run build --prefix client",
8
8
  "start": "npm run start --prefix server",
9
- "sync:template": "npx create-interview-cockpit upgrade"
9
+ "sync:template": "node scripts/sync-template.js"
10
10
  },
11
11
  "devDependencies": {
12
12
  "concurrently": "^9.1.0"