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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -740,15 +740,26 @@ export async function streamFrontendLabAsk(
740
740
 
741
741
  // --- Code Context ---
742
742
 
743
- export async function fetchCodeContextTree(): Promise<string[]> {
743
+ export interface CodeContextTreeResponse {
744
+ roots: import("./types").CodeContextRoot[];
745
+ }
746
+
747
+ export async function fetchCodeContextTree(): Promise<CodeContextTreeResponse> {
744
748
  const res = await fetch(`${BASE}/code-context/tree`);
745
- return res.json();
749
+ const data = await res.json();
750
+ if (Array.isArray(data)) {
751
+ return { roots: [{ id: "default", name: "Code Context", files: data }] };
752
+ }
753
+ return { roots: Array.isArray(data?.roots) ? data.roots : [] };
746
754
  }
747
755
 
748
756
  // --- Git Diff Context ---
749
757
 
750
758
  export interface GitBranchesResponse {
751
759
  enabled: boolean;
760
+ rootId?: string;
761
+ rootName?: string;
762
+ roots?: Array<{ id: string; name: string; path?: string }>;
752
763
  head?: string;
753
764
  defaultBranch?: string;
754
765
  branches: string[];
@@ -757,6 +768,8 @@ export interface GitBranchesResponse {
757
768
  }
758
769
 
759
770
  export interface GitDiffTreeResponse {
771
+ rootId?: string;
772
+ rootName?: string;
760
773
  base: string;
761
774
  baseSha?: string;
762
775
  head: string;
@@ -773,12 +786,18 @@ export interface GitDiffTreeResponse {
773
786
  }>;
774
787
  }
775
788
 
776
- export async function fetchGitBranches(): Promise<GitBranchesResponse> {
777
- const res = await fetch(`${BASE}/code-context/git/branches`);
789
+ export async function fetchGitBranches(params?: {
790
+ rootId?: string;
791
+ }): Promise<GitBranchesResponse> {
792
+ const qs = new URLSearchParams();
793
+ if (params?.rootId) qs.set("root", params.rootId);
794
+ const suffix = qs.toString() ? `?${qs}` : "";
795
+ const res = await fetch(`${BASE}/code-context/git/branches${suffix}`);
778
796
  return res.json();
779
797
  }
780
798
 
781
799
  export async function fetchGitDiffTree(params: {
800
+ rootId?: string;
782
801
  base: string;
783
802
  head: string;
784
803
  mode: "two-dot" | "three-dot" | "working-tree";
@@ -788,12 +807,14 @@ export async function fetchGitDiffTree(params: {
788
807
  head: params.head,
789
808
  mode: params.mode,
790
809
  });
810
+ if (params.rootId) qs.set("root", params.rootId);
791
811
  const res = await fetch(`${BASE}/code-context/git/diff-tree?${qs}`);
792
812
  if (!res.ok) throw new Error((await res.json()).error || "diff-tree failed");
793
813
  return res.json();
794
814
  }
795
815
 
796
816
  export async function fetchGitDiffFile(params: {
817
+ rootId?: string;
797
818
  base: string;
798
819
  head: string;
799
820
  mode: "two-dot" | "three-dot" | "working-tree";
@@ -807,6 +828,7 @@ export async function fetchGitDiffFile(params: {
807
828
  path: params.path,
808
829
  view: params.view,
809
830
  });
831
+ if (params.rootId) qs.set("root", params.rootId);
810
832
  const res = await fetch(`${BASE}/code-context/git/diff-file?${qs}`);
811
833
  if (!res.ok) throw new Error((await res.json()).error || "diff-file failed");
812
834
  return res.json();
@@ -1103,8 +1103,29 @@ function ContextSummary({
1103
1103
  questionFileCount: number;
1104
1104
  codeContextFiles: string[];
1105
1105
  }) {
1106
- const { openFileViewer } = useStore();
1106
+ const { openFileViewer, codeContextRoots } = useStore();
1107
1107
  const [expanded, setExpanded] = useState(false);
1108
+ const formatCodeFileLabel = (fileId: string) => {
1109
+ const sep = "::";
1110
+ const idx = fileId.indexOf(sep);
1111
+ const rootId = idx === -1 ? null : fileId.slice(0, idx);
1112
+ const relativePath = idx === -1 ? fileId : fileId.slice(idx + sep.length);
1113
+ const root = rootId
1114
+ ? codeContextRoots.find((candidate) => candidate.id === rootId)
1115
+ : codeContextRoots[0];
1116
+ const fileName = relativePath.split("/").pop() ?? relativePath;
1117
+ return root ? `${root.name} › ${fileName}` : fileName;
1118
+ };
1119
+ const formatCodeFileTitle = (fileId: string) => {
1120
+ const sep = "::";
1121
+ const idx = fileId.indexOf(sep);
1122
+ const rootId = idx === -1 ? null : fileId.slice(0, idx);
1123
+ const relativePath = idx === -1 ? fileId : fileId.slice(idx + sep.length);
1124
+ const root = rootId
1125
+ ? codeContextRoots.find((candidate) => candidate.id === rootId)
1126
+ : codeContextRoots[0];
1127
+ return root ? `${root.name}/${relativePath}` : relativePath;
1128
+ };
1108
1129
  const VISIBLE_COUNT = 2;
1109
1130
  const visibleFiles = expanded
1110
1131
  ? codeContextFiles
@@ -1128,10 +1149,10 @@ function ContextSummary({
1128
1149
  <button
1129
1150
  key={f}
1130
1151
  onClick={() => openFileViewer(f)}
1131
- title={f}
1152
+ title={formatCodeFileTitle(f)}
1132
1153
  className="bg-slate-800 hover:bg-slate-700 hover:text-slate-300 px-1.5 py-0.5 rounded transition-colors cursor-pointer"
1133
1154
  >
1134
- {f.split("/").pop()}
1155
+ {formatCodeFileLabel(f)}
1135
1156
  </button>
1136
1157
  ))}
1137
1158
  {!expanded && hiddenCount > 0 && (
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState, useCallback } from "react";
2
2
  import { useStore } from "../store";
3
+ import type { CodeContextRoot } from "../types";
3
4
  import FileViewerModal from "./FileViewerModal";
4
5
  import NotesModal, { notesKey } from "./NotesModal";
5
6
  import GitDiffPanel from "./GitDiffPanel";
@@ -35,6 +36,55 @@ interface TreeNode {
35
36
  files: string[]; // full file paths that are direct children
36
37
  }
37
38
 
39
+ const CODE_CONTEXT_ROOT_SEPARATOR = "::";
40
+
41
+ function codeContextFileId(rootId: string, filePath: string): string {
42
+ return `${rootId}${CODE_CONTEXT_ROOT_SEPARATOR}${filePath}`;
43
+ }
44
+
45
+ function parseCodeContextFileId(fileId: string) {
46
+ const idx = fileId.indexOf(CODE_CONTEXT_ROOT_SEPARATOR);
47
+ if (idx === -1) return { rootId: null, relativePath: fileId };
48
+ return {
49
+ rootId: fileId.slice(0, idx),
50
+ relativePath: fileId.slice(idx + CODE_CONTEXT_ROOT_SEPARATOR.length),
51
+ };
52
+ }
53
+
54
+ function normalizeSelectedCodeFiles(
55
+ files: string[],
56
+ roots: CodeContextRoot[],
57
+ ): string[] {
58
+ if (roots.length === 0) return files;
59
+ const rootIds = new Set(roots.map((root) => root.id));
60
+ const primaryRoot = roots[0];
61
+ return files.map((file) => {
62
+ const parsed = parseCodeContextFileId(file);
63
+ if (parsed.rootId && rootIds.has(parsed.rootId)) return file;
64
+ if (primaryRoot?.files.includes(file)) {
65
+ return codeContextFileId(primaryRoot.id, file);
66
+ }
67
+ return file;
68
+ });
69
+ }
70
+
71
+ function codeContextDisplayPath(fileId: string, roots: CodeContextRoot[]) {
72
+ const parsed = parseCodeContextFileId(fileId);
73
+ const root = parsed.rootId
74
+ ? roots.find((candidate) => candidate.id === parsed.rootId)
75
+ : roots[0];
76
+ return root ? `${root.name}/${parsed.relativePath}` : parsed.relativePath;
77
+ }
78
+
79
+ function codeContextShortLabel(fileId: string, roots: CodeContextRoot[]) {
80
+ const parsed = parseCodeContextFileId(fileId);
81
+ const root = parsed.rootId
82
+ ? roots.find((candidate) => candidate.id === parsed.rootId)
83
+ : roots[0];
84
+ const fileName = parsed.relativePath.split("/").pop() ?? parsed.relativePath;
85
+ return root ? `${root.name} › ${fileName}` : fileName;
86
+ }
87
+
38
88
  function buildTree(paths: string[]): TreeNode {
39
89
  const root: TreeNode = { name: "", path: "", children: [], files: [] };
40
90
 
@@ -70,6 +120,7 @@ function getAllFiles(node: TreeNode): string[] {
70
120
  // ─── Folder node component ──────────────────────────────
71
121
 
72
122
  interface FolderNodeProps {
123
+ rootId: string;
73
124
  node: TreeNode;
74
125
  selectedFiles: string[];
75
126
  onToggleFile: (path: string) => void;
@@ -82,6 +133,7 @@ interface FolderNodeProps {
82
133
  }
83
134
 
84
135
  function FolderNode({
136
+ rootId,
85
137
  node,
86
138
  selectedFiles,
87
139
  onToggleFile,
@@ -93,10 +145,12 @@ function FolderNode({
93
145
  onOpenFile,
94
146
  }: FolderNodeProps) {
95
147
  const allFiles = getAllFiles(node);
148
+ const allFileIds = allFiles.map((file) => codeContextFileId(rootId, file));
96
149
  const selectedCount = allFiles.filter((f) =>
97
- selectedFiles.includes(f),
150
+ selectedFiles.includes(codeContextFileId(rootId, f)),
98
151
  ).length;
99
- const isExpanded = expandedFolders.has(node.path);
152
+ const folderKey = codeContextFileId(rootId, node.path);
153
+ const isExpanded = expandedFolders.has(folderKey);
100
154
 
101
155
  // Filter: if searching, only show nodes that have matching files
102
156
  const matchingFiles = filter
@@ -130,7 +184,7 @@ function FolderNode({
130
184
  style={{ paddingLeft: `${depth * 12 + 4}px` }}
131
185
  >
132
186
  <button
133
- onClick={() => onToggleExpand(node.path)}
187
+ onClick={() => onToggleExpand(folderKey)}
134
188
  className="shrink-0 text-slate-500 hover:text-slate-300"
135
189
  >
136
190
  {isExpanded ? (
@@ -140,7 +194,7 @@ function FolderNode({
140
194
  )}
141
195
  </button>
142
196
  <button
143
- onClick={() => onToggleFolder(allFiles)}
197
+ onClick={() => onToggleFolder(allFileIds)}
144
198
  className={`shrink-0 ${checkColor} hover:text-cyan-300`}
145
199
  >
146
200
  <CheckIcon className="w-3.5 h-3.5" />
@@ -151,7 +205,7 @@ function FolderNode({
151
205
  <Folder className="w-3 h-3 text-amber-500/70 shrink-0" />
152
206
  )}
153
207
  <button
154
- onClick={() => onToggleExpand(node.path)}
208
+ onClick={() => onToggleExpand(folderKey)}
155
209
  className="text-xs text-slate-400 hover:text-slate-200 truncate text-left flex-1"
156
210
  >
157
211
  {node.name}
@@ -170,6 +224,7 @@ function FolderNode({
170
224
  .map((child) => (
171
225
  <FolderNode
172
226
  key={child.path}
227
+ rootId={rootId}
173
228
  node={child}
174
229
  selectedFiles={selectedFiles}
175
230
  onToggleFile={onToggleFile}
@@ -184,7 +239,8 @@ function FolderNode({
184
239
  {matchingFiles
185
240
  .sort((a, b) => a.localeCompare(b))
186
241
  .map((filePath) => {
187
- const isSelected = selectedFiles.includes(filePath);
242
+ const fileId = codeContextFileId(rootId, filePath);
243
+ const isSelected = selectedFiles.includes(fileId);
188
244
  const fileName = filePath.split("/").pop()!;
189
245
  return (
190
246
  <div
@@ -195,14 +251,164 @@ function FolderNode({
195
251
  style={{ paddingLeft: `${(depth + 1) * 12 + 4 + 14}px` }}
196
252
  >
197
253
  <button
198
- onClick={() => onToggleFile(filePath)}
254
+ onClick={() => onToggleFile(fileId)}
199
255
  className="flex items-center gap-1 flex-1 min-w-0 hover:text-slate-300 transition-colors text-xs text-left"
200
256
  >
201
257
  <File className="w-3 h-3 shrink-0" />
202
258
  <span className="truncate">{fileName}</span>
203
259
  </button>
204
260
  <button
205
- onClick={() => onOpenFile(filePath)}
261
+ onClick={() => onOpenFile(fileId)}
262
+ className="shrink-0 mr-1 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
263
+ title="View file"
264
+ >
265
+ <Eye className="w-3 h-3" />
266
+ </button>
267
+ </div>
268
+ );
269
+ })}
270
+ </div>
271
+ )}
272
+ </div>
273
+ );
274
+ }
275
+
276
+ interface CodeContextRootNodeProps {
277
+ root: CodeContextRoot;
278
+ selectedFiles: string[];
279
+ onToggleFile: (path: string) => void;
280
+ onToggleFolder: (paths: string[]) => void;
281
+ expandedFolders: Set<string>;
282
+ onToggleExpand: (path: string) => void;
283
+ filter: string;
284
+ onOpenFile: (path: string) => void;
285
+ }
286
+
287
+ function CodeContextRootNode({
288
+ root,
289
+ selectedFiles,
290
+ onToggleFile,
291
+ onToggleFolder,
292
+ expandedFolders,
293
+ onToggleExpand,
294
+ filter,
295
+ onOpenFile,
296
+ }: CodeContextRootNodeProps) {
297
+ const tree = buildTree(root.files);
298
+ const rootKey = `root${CODE_CONTEXT_ROOT_SEPARATOR}${root.id}`;
299
+ const rootFileIds = root.files.map((file) =>
300
+ codeContextFileId(root.id, file),
301
+ );
302
+ const selectedCount = rootFileIds.filter((fileId) =>
303
+ selectedFiles.includes(fileId),
304
+ ).length;
305
+ const rootMatches = filter ? root.name.toLowerCase().includes(filter) : false;
306
+ const effectiveFilter = rootMatches ? "" : filter;
307
+ const hasMatchingDescendants = filter
308
+ ? rootMatches ||
309
+ root.files.some((file) => file.toLowerCase().includes(filter))
310
+ : true;
311
+ if (filter && !hasMatchingDescendants) return null;
312
+
313
+ const isExpanded = rootMatches || expandedFolders.has(rootKey);
314
+ const checkState: "none" | "some" | "all" =
315
+ selectedCount === 0 || rootFileIds.length === 0
316
+ ? "none"
317
+ : selectedCount === rootFileIds.length
318
+ ? "all"
319
+ : "some";
320
+ const CheckIcon =
321
+ checkState === "all"
322
+ ? CheckSquare
323
+ : checkState === "some"
324
+ ? MinusSquare
325
+ : Square;
326
+ const checkColor = checkState === "none" ? "text-slate-600" : "text-cyan-400";
327
+
328
+ return (
329
+ <div className="border-b border-slate-800/60 last:border-b-0">
330
+ <div className="flex items-center gap-0.5 py-1 cursor-pointer group px-1">
331
+ <button
332
+ onClick={() => onToggleExpand(rootKey)}
333
+ className="shrink-0 text-slate-500 hover:text-slate-300"
334
+ >
335
+ {isExpanded ? (
336
+ <ChevronDown className="w-3 h-3" />
337
+ ) : (
338
+ <ChevronRight className="w-3 h-3" />
339
+ )}
340
+ </button>
341
+ <button
342
+ onClick={() => onToggleFolder(rootFileIds)}
343
+ className={`shrink-0 ${checkColor} hover:text-cyan-300`}
344
+ title={`Select all files in ${root.name}`}
345
+ >
346
+ <CheckIcon className="w-3.5 h-3.5" />
347
+ </button>
348
+ {isExpanded ? (
349
+ <FolderOpen className="w-3 h-3 text-cyan-500/70 shrink-0" />
350
+ ) : (
351
+ <Folder className="w-3 h-3 text-cyan-500/70 shrink-0" />
352
+ )}
353
+ <button
354
+ onClick={() => onToggleExpand(rootKey)}
355
+ className="text-xs font-semibold text-slate-300 hover:text-slate-100 truncate text-left flex-1"
356
+ title={root.name}
357
+ >
358
+ {root.name}
359
+ </button>
360
+ <span className="text-[10px] text-slate-600 shrink-0 mr-1">
361
+ {selectedCount > 0 ? `${selectedCount}/` : ""}
362
+ {root.files.length}
363
+ </span>
364
+ </div>
365
+
366
+ {isExpanded && (
367
+ <div className="pb-1">
368
+ {tree.children
369
+ .sort((a, b) => a.name.localeCompare(b.name))
370
+ .map((child) => (
371
+ <FolderNode
372
+ key={`${root.id}:${child.path}`}
373
+ rootId={root.id}
374
+ node={child}
375
+ selectedFiles={selectedFiles}
376
+ onToggleFile={onToggleFile}
377
+ onToggleFolder={onToggleFolder}
378
+ expandedFolders={expandedFolders}
379
+ onToggleExpand={onToggleExpand}
380
+ depth={1}
381
+ filter={effectiveFilter}
382
+ onOpenFile={onOpenFile}
383
+ />
384
+ ))}
385
+ {tree.files
386
+ .filter(
387
+ (file) =>
388
+ !effectiveFilter ||
389
+ file.toLowerCase().includes(effectiveFilter),
390
+ )
391
+ .sort((a, b) => a.localeCompare(b))
392
+ .map((filePath) => {
393
+ const fileId = codeContextFileId(root.id, filePath);
394
+ const isSelected = selectedFiles.includes(fileId);
395
+ return (
396
+ <div
397
+ key={fileId}
398
+ className={`flex items-center py-0.5 group ${
399
+ isSelected ? "text-cyan-400" : "text-slate-500"
400
+ }`}
401
+ style={{ paddingLeft: 30 }}
402
+ >
403
+ <button
404
+ onClick={() => onToggleFile(fileId)}
405
+ className="flex items-center gap-1.5 flex-1 min-w-0 hover:text-slate-300 transition-colors text-xs text-left"
406
+ >
407
+ <File className="w-3 h-3 shrink-0" />
408
+ <span className="truncate">{filePath}</span>
409
+ </button>
410
+ <button
411
+ onClick={() => onOpenFile(fileId)}
206
412
  className="shrink-0 mr-1 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
207
413
  title="View file"
208
414
  >
@@ -222,6 +428,7 @@ function FolderNode({
222
428
  export default function CodeContextPanel() {
223
429
  const {
224
430
  availableFiles,
431
+ codeContextRoots,
225
432
  currentQuestion,
226
433
  fetchAvailableFiles,
227
434
  updateCodeContext,
@@ -263,10 +470,17 @@ export default function CodeContextPanel() {
263
470
  }, []);
264
471
 
265
472
  useEffect(() => {
266
- setSelectedFiles(currentQuestion?.codeContextFiles || []);
267
- }, [currentQuestion?.id]);
268
-
269
- const tree = buildTree(availableFiles);
473
+ setSelectedFiles(
474
+ normalizeSelectedCodeFiles(
475
+ currentQuestion?.codeContextFiles || [],
476
+ codeContextRoots,
477
+ ),
478
+ );
479
+ }, [
480
+ currentQuestion?.id,
481
+ currentQuestion?.codeContextFiles,
482
+ codeContextRoots,
483
+ ]);
270
484
 
271
485
  const toggleFile = useCallback(
272
486
  (filePath: string) => {
@@ -432,9 +646,9 @@ export default function CodeContextPanel() {
432
646
  <button
433
647
  onClick={() => setViewingFile(f)}
434
648
  className="truncate flex-1 text-left hover:underline"
435
- title="View file"
649
+ title={codeContextDisplayPath(f, codeContextRoots)}
436
650
  >
437
- {f.split("/").pop()}
651
+ {codeContextShortLabel(f, codeContextRoots)}
438
652
  </button>
439
653
  <button
440
654
  onClick={() => toggleFile(f)}
@@ -457,59 +671,27 @@ export default function CodeContextPanel() {
457
671
  ) : availableFiles.length === 0 ? (
458
672
  <div className="p-3 text-center">
459
673
  <p className="text-xs text-slate-600">
460
- Set CODE_CONTEXT_DIR in .env
674
+ Set CODE_CONTEXT_DIR or CODE_CONTEXT_DIRS in .env
461
675
  </p>
462
676
  <p className="text-xs text-slate-700 mt-1">
463
677
  to browse project files
464
678
  </p>
465
679
  </div>
466
680
  ) : (
467
- tree.children
468
- .sort((a, b) => a.name.localeCompare(b.name))
469
- .map((child) => (
470
- <FolderNode
471
- key={child.path}
472
- node={child}
473
- selectedFiles={selectedFiles}
474
- onToggleFile={toggleFile}
475
- onToggleFolder={toggleFolder}
476
- expandedFolders={expandedFolders}
477
- onToggleExpand={toggleExpand}
478
- depth={0}
479
- filter={filter}
480
- onOpenFile={setViewingFile}
481
- />
482
- ))
681
+ codeContextRoots.map((root) => (
682
+ <CodeContextRootNode
683
+ key={root.id}
684
+ root={root}
685
+ selectedFiles={selectedFiles}
686
+ onToggleFile={toggleFile}
687
+ onToggleFolder={toggleFolder}
688
+ expandedFolders={expandedFolders}
689
+ onToggleExpand={toggleExpand}
690
+ filter={filter}
691
+ onOpenFile={setViewingFile}
692
+ />
693
+ ))
483
694
  )}
484
- {/* Root-level files (if any) */}
485
- {tree.files
486
- .filter((f) => !filter || f.toLowerCase().includes(filter))
487
- .map((filePath) => {
488
- const isSelected = selectedFiles.includes(filePath);
489
- return (
490
- <div
491
- key={filePath}
492
- className={`flex items-center px-4 py-0.5 group ${
493
- isSelected ? "text-cyan-400" : "text-slate-500"
494
- }`}
495
- >
496
- <button
497
- onClick={() => toggleFile(filePath)}
498
- className="flex items-center gap-1.5 flex-1 min-w-0 hover:text-slate-300 transition-colors text-xs text-left"
499
- >
500
- <File className="w-3 h-3 shrink-0" />
501
- <span className="truncate">{filePath}</span>
502
- </button>
503
- <button
504
- onClick={() => setViewingFile(filePath)}
505
- className="shrink-0 mr-1 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
506
- title="View file"
507
- >
508
- <Eye className="w-3 h-3" />
509
- </button>
510
- </div>
511
- );
512
- })}
513
695
  </div>
514
696
 
515
697
  {/* ── My Code section ─────────────────────────────────── */}