create-interview-cockpit 0.24.0 → 0.26.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.
@@ -5,8 +5,14 @@ import {
5
5
  Eye,
6
6
  X,
7
7
  Check,
8
+ CheckSquare,
8
9
  ChevronDown,
9
10
  ChevronRight,
11
+ File,
12
+ Folder,
13
+ FolderOpen,
14
+ MinusSquare,
15
+ Square,
10
16
  } from "lucide-react";
11
17
  import { useStore } from "../store";
12
18
  import {
@@ -30,12 +36,265 @@ const STATUS_META: Record<string, { color: string; label: string }> = {
30
36
  "?": { color: "text-slate-400", label: "untracked" },
31
37
  };
32
38
 
39
+ const DIFF_ROOT_SEPARATOR = "::";
40
+
41
+ type DiffFileEntry = GitDiffTreeResponse["files"][number];
42
+
43
+ interface DiffTreeNode {
44
+ name: string;
45
+ path: string;
46
+ children: DiffTreeNode[];
47
+ files: DiffFileEntry[];
48
+ }
49
+
50
+ function diffFileId(rootId: string | undefined, filePath: string): string {
51
+ return rootId ? `${rootId}${DIFF_ROOT_SEPARATOR}${filePath}` : filePath;
52
+ }
53
+
54
+ function parseDiffFileId(fileId: string) {
55
+ const idx = fileId.indexOf(DIFF_ROOT_SEPARATOR);
56
+ if (idx === -1) return { rootId: null, relativePath: fileId };
57
+ return {
58
+ rootId: fileId.slice(0, idx),
59
+ relativePath: fileId.slice(idx + DIFF_ROOT_SEPARATOR.length),
60
+ };
61
+ }
62
+
63
+ function normalizeDiffSelection(files: string[], rootId?: string): string[] {
64
+ if (!rootId) return files;
65
+ return files.map((file) => {
66
+ const parsed = parseDiffFileId(file);
67
+ return parsed.rootId ? file : diffFileId(rootId, file);
68
+ });
69
+ }
70
+
71
+ function buildDiffTree(files: DiffFileEntry[]): DiffTreeNode {
72
+ const root: DiffTreeNode = { name: "", path: "", children: [], files: [] };
73
+ for (const file of files) {
74
+ const parts = file.path.split("/");
75
+ let current = root;
76
+ let currentPath = "";
77
+ for (let i = 0; i < parts.length; i += 1) {
78
+ const part = parts[i];
79
+ const isFile = i === parts.length - 1;
80
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
81
+ if (isFile) {
82
+ current.files.push(file);
83
+ } else {
84
+ let child = current.children.find(
85
+ (candidate) => candidate.name === part,
86
+ );
87
+ if (!child) {
88
+ child = { name: part, path: currentPath, children: [], files: [] };
89
+ current.children.push(child);
90
+ }
91
+ current = child;
92
+ }
93
+ }
94
+ }
95
+ return root;
96
+ }
97
+
98
+ function getAllDiffFilePaths(node: DiffTreeNode): string[] {
99
+ return [
100
+ ...node.files.map((file) => file.path),
101
+ ...node.children.flatMap(getAllDiffFilePaths),
102
+ ];
103
+ }
104
+
105
+ function DiffFileRow({
106
+ file,
107
+ rootId,
108
+ selected,
109
+ onToggleFile,
110
+ onViewFile,
111
+ depth,
112
+ }: {
113
+ file: DiffFileEntry;
114
+ rootId?: string;
115
+ selected: string[];
116
+ onToggleFile: (path: string) => void;
117
+ onViewFile: (path: string) => void;
118
+ depth: number;
119
+ }) {
120
+ const meta = STATUS_META[file.status] ?? STATUS_META.M;
121
+ const fileId = diffFileId(rootId, file.path);
122
+ const legacySelected = selected.includes(file.path);
123
+ const isSelected = selected.includes(fileId) || legacySelected;
124
+ return (
125
+ <div
126
+ className={`flex items-center gap-1 text-xs group rounded px-1 py-0.5 ${
127
+ isSelected ? "bg-cyan-500/10" : ""
128
+ }`}
129
+ style={{ paddingLeft: depth * 12 + 4 }}
130
+ >
131
+ <button
132
+ onClick={() => onToggleFile(file.path)}
133
+ className="shrink-0"
134
+ title={meta.label}
135
+ >
136
+ {isSelected ? (
137
+ <Check className="w-3 h-3 text-cyan-400" />
138
+ ) : (
139
+ <span
140
+ className={`w-3 h-3 inline-flex items-center justify-center text-[10px] font-mono ${meta.color}`}
141
+ >
142
+ {file.status}
143
+ </span>
144
+ )}
145
+ </button>
146
+ <button
147
+ onClick={() => onToggleFile(file.path)}
148
+ className={`flex items-center gap-1 flex-1 min-w-0 text-left truncate ${
149
+ isSelected ? "text-cyan-300" : "text-slate-400"
150
+ } hover:text-slate-200`}
151
+ title={file.oldPath ? `${file.oldPath} → ${file.path}` : file.path}
152
+ >
153
+ <File className="w-3 h-3 shrink-0 text-slate-600" />
154
+ <span className="truncate">{file.path.split("/").pop()}</span>
155
+ </button>
156
+ <span
157
+ className={`shrink-0 text-[10px] font-mono ${
158
+ file.binary ? "text-slate-600" : "text-slate-500"
159
+ }`}
160
+ >
161
+ {file.binary ? "bin" : `+${file.additions}/-${file.deletions}`}
162
+ </span>
163
+ <button
164
+ onClick={() => onViewFile(file.path)}
165
+ className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
166
+ title="View diff"
167
+ >
168
+ <Eye className="w-3 h-3" />
169
+ </button>
170
+ </div>
171
+ );
172
+ }
173
+
174
+ function DiffFolderNode({
175
+ node,
176
+ rootId,
177
+ selected,
178
+ expandedFolders,
179
+ onToggleExpand,
180
+ onToggleFile,
181
+ onToggleFolder,
182
+ onViewFile,
183
+ depth,
184
+ }: {
185
+ node: DiffTreeNode;
186
+ rootId?: string;
187
+ selected: string[];
188
+ expandedFolders: Set<string>;
189
+ onToggleExpand: (path: string) => void;
190
+ onToggleFile: (path: string) => void;
191
+ onToggleFolder: (paths: string[]) => void;
192
+ onViewFile: (path: string) => void;
193
+ depth: number;
194
+ }) {
195
+ const folderKey = diffFileId(rootId, node.path);
196
+ const isExpanded = expandedFolders.has(folderKey);
197
+ const allPaths = getAllDiffFilePaths(node);
198
+ const allIds = allPaths.map((path) => diffFileId(rootId, path));
199
+ const selectedCount = allIds.filter((id) => selected.includes(id)).length;
200
+ const checkState: "none" | "some" | "all" =
201
+ selectedCount === 0
202
+ ? "none"
203
+ : selectedCount === allIds.length
204
+ ? "all"
205
+ : "some";
206
+ const CheckIcon =
207
+ checkState === "all"
208
+ ? CheckSquare
209
+ : checkState === "some"
210
+ ? MinusSquare
211
+ : Square;
212
+ const checkColor = checkState === "none" ? "text-slate-600" : "text-cyan-400";
213
+
214
+ return (
215
+ <div>
216
+ <div
217
+ className="flex items-center gap-1 text-xs group rounded px-1 py-0.5"
218
+ style={{ paddingLeft: depth * 12 + 4 }}
219
+ >
220
+ <button
221
+ onClick={() => onToggleExpand(folderKey)}
222
+ className="shrink-0 text-slate-500 hover:text-slate-300"
223
+ >
224
+ {isExpanded ? (
225
+ <ChevronDown className="w-3 h-3" />
226
+ ) : (
227
+ <ChevronRight className="w-3 h-3" />
228
+ )}
229
+ </button>
230
+ <button
231
+ onClick={() => onToggleFolder(allPaths)}
232
+ className={`shrink-0 ${checkColor} hover:text-cyan-300`}
233
+ title="Select folder changes"
234
+ >
235
+ <CheckIcon className="w-3.5 h-3.5" />
236
+ </button>
237
+ {isExpanded ? (
238
+ <FolderOpen className="w-3 h-3 text-amber-500/70 shrink-0" />
239
+ ) : (
240
+ <Folder className="w-3 h-3 text-amber-500/70 shrink-0" />
241
+ )}
242
+ <button
243
+ onClick={() => onToggleExpand(folderKey)}
244
+ className="text-slate-400 hover:text-slate-200 truncate text-left flex-1"
245
+ title={node.path}
246
+ >
247
+ {node.name}
248
+ </button>
249
+ <span className="text-[10px] text-slate-600 shrink-0">
250
+ {selectedCount > 0 ? `${selectedCount}/` : ""}
251
+ {allIds.length}
252
+ </span>
253
+ </div>
254
+ {isExpanded && (
255
+ <div>
256
+ {node.children
257
+ .sort((a, b) => a.name.localeCompare(b.name))
258
+ .map((child) => (
259
+ <DiffFolderNode
260
+ key={child.path}
261
+ node={child}
262
+ rootId={rootId}
263
+ selected={selected}
264
+ expandedFolders={expandedFolders}
265
+ onToggleExpand={onToggleExpand}
266
+ onToggleFile={onToggleFile}
267
+ onToggleFolder={onToggleFolder}
268
+ onViewFile={onViewFile}
269
+ depth={depth + 1}
270
+ />
271
+ ))}
272
+ {node.files
273
+ .sort((a, b) => a.path.localeCompare(b.path))
274
+ .map((file) => (
275
+ <DiffFileRow
276
+ key={file.path}
277
+ file={file}
278
+ rootId={rootId}
279
+ selected={selected}
280
+ onToggleFile={onToggleFile}
281
+ onViewFile={onViewFile}
282
+ depth={depth + 1}
283
+ />
284
+ ))}
285
+ </div>
286
+ )}
287
+ </div>
288
+ );
289
+ }
290
+
33
291
  export default function GitDiffPanel() {
34
292
  const { currentQuestion, updateGitDiffContext } = useStore();
35
293
  const existing = currentQuestion?.gitDiffContext;
36
294
 
37
295
  const [expanded, setExpanded] = useState<boolean>(Boolean(existing));
38
296
  const [branches, setBranches] = useState<GitBranchesResponse | null>(null);
297
+ const [rootId, setRootId] = useState<string>(existing?.rootId || "");
39
298
  const [baseRef, setBaseRef] = useState<string>(existing?.baseRef || "");
40
299
  const [headRef, setHeadRef] = useState<string>(existing?.headRef || "");
41
300
  const [mode, setMode] = useState<GitDiffMode>(existing?.mode || "three-dot");
@@ -43,17 +302,25 @@ export default function GitDiffPanel() {
43
302
  const [loadingTree, setLoadingTree] = useState(false);
44
303
  const [error, setError] = useState<string | null>(null);
45
304
  const [selected, setSelected] = useState<string[]>(
46
- existing?.selectedFiles || [],
305
+ normalizeDiffSelection(existing?.selectedFiles || [], existing?.rootId),
306
+ );
307
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
308
+ () => new Set(),
47
309
  );
48
310
  const [viewingFile, setViewingFile] = useState<string | null>(null);
49
311
 
50
312
  // Reload selection when switching questions.
51
313
  useEffect(() => {
314
+ setRootId(existing?.rootId || "");
52
315
  setBaseRef(existing?.baseRef || "");
53
316
  setHeadRef(existing?.headRef || "");
54
317
  setMode(existing?.mode || "three-dot");
55
- setSelected(existing?.selectedFiles || []);
318
+ setSelected(
319
+ normalizeDiffSelection(existing?.selectedFiles || [], existing?.rootId),
320
+ );
321
+ setBranches(null);
56
322
  setTree(null);
323
+ setExpandedFolders(new Set());
57
324
  }, [currentQuestion?.id]);
58
325
 
59
326
  const applyBranchDefaults = useCallback(
@@ -84,20 +351,17 @@ export default function GitDiffPanel() {
84
351
  [selected.length],
85
352
  );
86
353
 
87
- // Lazy-load branches the first time the section is opened.
354
+ // Lazy-load branches for the active diff source root.
88
355
  useEffect(() => {
89
- if (!expanded) return;
90
- if (branches) {
91
- applyBranchDefaults(branches);
92
- return;
93
- }
94
- fetchGitBranches()
356
+ if (!expanded || !currentQuestion) return;
357
+ fetchGitBranches({ rootId: rootId || undefined })
95
358
  .then((b) => {
96
359
  setBranches(b);
360
+ setRootId((prev) => prev || b.rootId || b.roots?.[0]?.id || "");
97
361
  applyBranchDefaults(b);
98
362
  })
99
363
  .catch((e) => setError(e?.message || "Failed to load branches"));
100
- }, [expanded, branches, applyBranchDefaults]);
364
+ }, [expanded, currentQuestion?.id, rootId]);
101
365
 
102
366
  const loadTree = useCallback(async () => {
103
367
  if (!baseRef) return;
@@ -105,18 +369,22 @@ export default function GitDiffPanel() {
105
369
  setError(null);
106
370
  try {
107
371
  const data = await fetchGitDiffTree({
372
+ rootId: rootId || branches?.rootId,
108
373
  base: baseRef,
109
374
  head: mode === "working-tree" ? "" : headRef,
110
375
  mode,
111
376
  });
112
377
  setTree(data);
378
+ const nextRootId = data.rootId || rootId || branches?.rootId;
379
+ setSelected((prev) => normalizeDiffSelection(prev, nextRootId));
380
+ setExpandedFolders(new Set());
113
381
  } catch (e: any) {
114
382
  setTree(null);
115
383
  setError(e?.message || "Failed to load diff");
116
384
  } finally {
117
385
  setLoadingTree(false);
118
386
  }
119
- }, [baseRef, headRef, mode]);
387
+ }, [rootId, branches?.rootId, baseRef, headRef, mode]);
120
388
 
121
389
  // Auto-load when refs/mode change and the section is open.
122
390
  useEffect(() => {
@@ -124,12 +392,15 @@ export default function GitDiffPanel() {
124
392
  if (!baseRef) return;
125
393
  if (mode !== "working-tree" && !headRef) return;
126
394
  loadTree();
127
- }, [expanded, baseRef, headRef, mode]);
395
+ }, [expanded, baseRef, headRef, mode, loadTree]);
396
+
397
+ const activeRootId = tree?.rootId || rootId || branches?.rootId || "";
128
398
 
129
399
  const persist = useCallback(
130
400
  (next: Partial<GitDiffContext>) => {
131
401
  if (!currentQuestion) return;
132
402
  const ctx: GitDiffContext = {
403
+ rootId: activeRootId,
133
404
  baseRef,
134
405
  headRef: mode === "working-tree" ? "" : headRef,
135
406
  mode,
@@ -143,20 +414,68 @@ export default function GitDiffPanel() {
143
414
  updateGitDiffContext(currentQuestion.id, ctx);
144
415
  }
145
416
  },
146
- [currentQuestion, baseRef, headRef, mode, selected, updateGitDiffContext],
417
+ [
418
+ currentQuestion,
419
+ activeRootId,
420
+ baseRef,
421
+ headRef,
422
+ mode,
423
+ selected,
424
+ updateGitDiffContext,
425
+ ],
147
426
  );
148
427
 
149
428
  const toggleFile = useCallback(
150
429
  (filePath: string) => {
430
+ const id = diffFileId(activeRootId, filePath);
431
+ setSelected((prev) => {
432
+ const isSelected = prev.includes(id) || prev.includes(filePath);
433
+ const next = isSelected
434
+ ? prev.filter((f) => f !== id && f !== filePath)
435
+ : [...prev, id];
436
+ persist({ rootId: activeRootId, selectedFiles: next });
437
+ return next;
438
+ });
439
+ },
440
+ [activeRootId, persist],
441
+ );
442
+
443
+ const toggleFolder = useCallback(
444
+ (paths: string[]) => {
445
+ const ids = paths.map((path) => diffFileId(activeRootId, path));
151
446
  setSelected((prev) => {
152
- const next = prev.includes(filePath)
153
- ? prev.filter((f) => f !== filePath)
154
- : [...prev, filePath];
155
- persist({ selectedFiles: next });
447
+ const allSelected = ids.every((id) => prev.includes(id));
448
+ const next = allSelected
449
+ ? prev.filter((file) => !ids.includes(file) && !paths.includes(file))
450
+ : Array.from(new Set([...prev, ...ids]));
451
+ persist({ rootId: activeRootId, selectedFiles: next });
156
452
  return next;
157
453
  });
158
454
  },
159
- [persist],
455
+ [activeRootId, persist],
456
+ );
457
+
458
+ const toggleExpand = useCallback((path: string) => {
459
+ setExpandedFolders((prev) => {
460
+ const next = new Set(prev);
461
+ if (next.has(path)) next.delete(path);
462
+ else next.add(path);
463
+ return next;
464
+ });
465
+ }, []);
466
+
467
+ const changeRoot = useCallback(
468
+ (nextRootId: string) => {
469
+ setRootId(nextRootId);
470
+ setBranches(null);
471
+ setBaseRef("");
472
+ setHeadRef("");
473
+ setTree(null);
474
+ setSelected([]);
475
+ setExpandedFolders(new Set());
476
+ if (currentQuestion) updateGitDiffContext(currentQuestion.id, null);
477
+ },
478
+ [currentQuestion, updateGitDiffContext],
160
479
  );
161
480
 
162
481
  const clearSelection = useCallback(() => {
@@ -166,6 +485,8 @@ export default function GitDiffPanel() {
166
485
 
167
486
  const branchOptions = branches?.branches ?? [];
168
487
  const tagOptions = branches?.tags ?? [];
488
+ const rootOptions = branches?.roots ?? [];
489
+ const diffTree = tree ? buildDiffTree(tree.files) : null;
169
490
 
170
491
  return (
171
492
  <div className="border-t border-slate-800">
@@ -192,10 +513,29 @@ export default function GitDiffPanel() {
192
513
  </p>
193
514
  )}
194
515
 
516
+ {currentQuestion && rootOptions.length > 1 && (
517
+ <div className="space-y-1">
518
+ <label className="block text-[10px] uppercase tracking-wider text-slate-600">
519
+ Source
520
+ </label>
521
+ <select
522
+ value={activeRootId}
523
+ onChange={(e) => changeRoot(e.target.value)}
524
+ className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
525
+ >
526
+ {rootOptions.map((root) => (
527
+ <option key={root.id} value={root.id}>
528
+ {root.name}
529
+ </option>
530
+ ))}
531
+ </select>
532
+ </div>
533
+ )}
534
+
195
535
  {currentQuestion && branches && !branches.enabled && (
196
536
  <p className="text-[10px] text-slate-600 italic">
197
- Set <code>GIT_DIFF_DIR</code> (or <code>CODE_CONTEXT_DIR</code>)
198
- to a git repo to enable diffs.
537
+ Set <code>GIT_DIFF_DIR</code>, <code>GIT_DIFF_DIRS</code>, or a
538
+ git-backed <code>CODE_CONTEXT_DIR</code> to enable diffs.
199
539
  {branches.error ? ` (${branches.error})` : ""}
200
540
  </p>
201
541
  )}
@@ -272,64 +612,53 @@ export default function GitDiffPanel() {
272
612
 
273
613
  {tree && (
274
614
  <div className="space-y-0.5 max-h-72 overflow-y-auto border-t border-slate-800 pt-2">
615
+ {tree.rootName && (
616
+ <div className="text-[10px] text-slate-600 px-1 pb-1 truncate">
617
+ {tree.rootName} · {tree.base}{" "}
618
+ {tree.mode === "two-dot"
619
+ ? ".."
620
+ : tree.mode === "working-tree"
621
+ ? "→"
622
+ : "..."}{" "}
623
+ {tree.mode === "working-tree"
624
+ ? "working tree"
625
+ : tree.head}
626
+ </div>
627
+ )}
275
628
  {tree.files.length === 0 && (
276
629
  <p className="text-[10px] text-slate-600 italic">
277
630
  No changes between these refs.
278
631
  </p>
279
632
  )}
280
- {tree.files.map((f) => {
281
- const meta = STATUS_META[f.status] ?? STATUS_META["M"];
282
- const isSelected = selected.includes(f.path);
283
- return (
284
- <div
285
- key={f.path}
286
- className={`flex items-center gap-1 text-xs group rounded px-1 py-0.5 ${
287
- isSelected ? "bg-cyan-500/10" : ""
288
- }`}
289
- >
290
- <button
291
- onClick={() => toggleFile(f.path)}
292
- className="shrink-0"
293
- title={meta.label}
294
- >
295
- {isSelected ? (
296
- <Check className="w-3 h-3 text-cyan-400" />
297
- ) : (
298
- <span
299
- className={`w-3 h-3 inline-flex items-center justify-center text-[10px] font-mono ${meta.color}`}
300
- >
301
- {f.status}
302
- </span>
303
- )}
304
- </button>
305
- <button
306
- onClick={() => toggleFile(f.path)}
307
- className={`flex-1 min-w-0 text-left truncate ${
308
- isSelected ? "text-cyan-300" : "text-slate-400"
309
- } hover:text-slate-200`}
310
- title={
311
- f.oldPath ? `${f.oldPath} → ${f.path}` : f.path
312
- }
313
- >
314
- {f.path}
315
- </button>
316
- <span
317
- className={`shrink-0 text-[10px] font-mono ${
318
- f.binary ? "text-slate-600" : "text-slate-500"
319
- }`}
320
- >
321
- {f.binary ? "bin" : `+${f.additions}/-${f.deletions}`}
322
- </span>
323
- <button
324
- onClick={() => setViewingFile(f.path)}
325
- className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
326
- title="View diff"
327
- >
328
- <Eye className="w-3 h-3" />
329
- </button>
330
- </div>
331
- );
332
- })}
633
+ {diffTree?.children
634
+ .sort((a, b) => a.name.localeCompare(b.name))
635
+ .map((child) => (
636
+ <DiffFolderNode
637
+ key={child.path}
638
+ node={child}
639
+ rootId={tree.rootId || activeRootId}
640
+ selected={selected}
641
+ expandedFolders={expandedFolders}
642
+ onToggleExpand={toggleExpand}
643
+ onToggleFile={toggleFile}
644
+ onToggleFolder={toggleFolder}
645
+ onViewFile={setViewingFile}
646
+ depth={0}
647
+ />
648
+ ))}
649
+ {diffTree?.files
650
+ .sort((a, b) => a.path.localeCompare(b.path))
651
+ .map((file) => (
652
+ <DiffFileRow
653
+ key={file.path}
654
+ file={file}
655
+ rootId={tree.rootId || activeRootId}
656
+ selected={selected}
657
+ onToggleFile={toggleFile}
658
+ onViewFile={setViewingFile}
659
+ depth={0}
660
+ />
661
+ ))}
333
662
  {tree.truncated && (
334
663
  <p className="text-[10px] text-amber-400/70 mt-1">
335
664
  Truncated — too many changed files.
@@ -345,6 +674,7 @@ export default function GitDiffPanel() {
345
674
  {viewingFile && baseRef && (mode === "working-tree" || headRef) && (
346
675
  <GitDiffViewerModal
347
676
  ctx={{
677
+ rootId: activeRootId,
348
678
  baseRef,
349
679
  headRef: mode === "working-tree" ? "" : headRef,
350
680
  mode,
@@ -25,6 +25,7 @@ export default function GitDiffViewerModal({ ctx, filePath, onClose }: Props) {
25
25
  setLoading(true);
26
26
  setError(null);
27
27
  fetchGitDiffFile({
28
+ rootId: ctx.rootId,
28
29
  base: ctx.baseRef,
29
30
  head: ctx.headRef,
30
31
  mode: ctx.mode,
@@ -43,7 +44,7 @@ export default function GitDiffViewerModal({ ctx, filePath, onClose }: Props) {
43
44
  return () => {
44
45
  cancelled = true;
45
46
  };
46
- }, [ctx.baseRef, ctx.headRef, ctx.mode, filePath, view]);
47
+ }, [ctx.rootId, ctx.baseRef, ctx.headRef, ctx.mode, filePath, view]);
47
48
 
48
49
  const language =
49
50
  view === "patch"
@@ -489,7 +489,14 @@ export default function MarkdownRenderer({
489
489
  );
490
490
  }
491
491
  if (href?.startsWith("coderef://")) {
492
- const filePath = href.slice("coderef://".length);
492
+ const rawFilePath = href.slice("coderef://".length);
493
+ const filePath = (() => {
494
+ try {
495
+ return decodeURIComponent(rawFilePath);
496
+ } catch {
497
+ return rawFilePath;
498
+ }
499
+ })();
493
500
  return (
494
501
  <button
495
502
  type="button"
@@ -8,6 +8,7 @@ import type {
8
8
  FrontendLabWorkspace,
9
9
  GithubActionsLabWorkspace,
10
10
  ContextFileOrigin,
11
+ CodeContextRoot,
11
12
  } from "./types";
12
13
  import type { AiSettings } from "./api";
13
14
  import * as api from "./api";
@@ -99,6 +100,12 @@ const DEFAULT_AI_SETTINGS: AiSettings = {
99
100
  },
100
101
  };
101
102
 
103
+ const CODE_CONTEXT_ROOT_SEPARATOR = "::";
104
+
105
+ function codeContextFileId(rootId: string, filePath: string): string {
106
+ return `${rootId}${CODE_CONTEXT_ROOT_SEPARATOR}${filePath}`;
107
+ }
108
+
102
109
  interface Store {
103
110
  topics: Topic[];
104
111
  questionsByTopic: Record<string, Question[]>;
@@ -106,6 +113,7 @@ interface Store {
106
113
  selectedQuestionId: string | null;
107
114
  currentQuestion: Question | null;
108
115
  expandedTopics: string[];
116
+ codeContextRoots: CodeContextRoot[];
109
117
  availableFiles: string[];
110
118
  showCodePanel: boolean;
111
119
  showLabsPanel: boolean;
@@ -388,6 +396,7 @@ export const useStore = create<Store>((set, get) => ({
388
396
  selectedQuestionId: null,
389
397
  currentQuestion: null,
390
398
  expandedTopics: [],
399
+ codeContextRoots: [],
391
400
  availableFiles: [],
392
401
  showCodePanel: false,
393
402
  showLabsPanel: false,
@@ -821,8 +830,14 @@ export const useStore = create<Store>((set, get) => ({
821
830
  },
822
831
 
823
832
  fetchAvailableFiles: async () => {
824
- const files = await api.fetchCodeContextTree();
825
- set({ availableFiles: files });
833
+ const tree = await api.fetchCodeContextTree();
834
+ const roots = tree.roots || [];
835
+ set({
836
+ codeContextRoots: roots,
837
+ availableFiles: roots.flatMap((root) =>
838
+ root.files.map((file) => codeContextFileId(root.id, file)),
839
+ ),
840
+ });
826
841
  },
827
842
 
828
843
  updateCodeContext: async (questionId, files) => {