create-interview-cockpit 0.26.1 → 0.27.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.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -150,7 +150,7 @@ function FolderNode({
150
150
  selectedFiles.includes(codeContextFileId(rootId, f)),
151
151
  ).length;
152
152
  const folderKey = codeContextFileId(rootId, node.path);
153
- const isExpanded = expandedFolders.has(folderKey);
153
+ const isExpanded = Boolean(filter) || expandedFolders.has(folderKey);
154
154
 
155
155
  // Filter: if searching, only show nodes that have matching files
156
156
  const matchingFiles = filter
@@ -310,7 +310,8 @@ function CodeContextRootNode({
310
310
  : true;
311
311
  if (filter && !hasMatchingDescendants) return null;
312
312
 
313
- const isExpanded = rootMatches || expandedFolders.has(rootKey);
313
+ const isExpanded =
314
+ Boolean(filter) || rootMatches || expandedFolders.has(rootKey);
314
315
  const checkState: "none" | "some" | "all" =
315
316
  selectedCount === 0 || rootFileIds.length === 0
316
317
  ? "none"
@@ -1,4 +1,12 @@
1
- import { memo, useEffect, useMemo, useRef, useState, useCallback } from "react";
1
+ import {
2
+ memo,
3
+ useDeferredValue,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ useCallback,
9
+ } from "react";
2
10
  import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
3
11
  import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
4
12
  import {
@@ -12,9 +20,12 @@ import {
12
20
  MessageCircle,
13
21
  Send,
14
22
  Play,
23
+ Search,
24
+ ChevronUp,
25
+ ChevronDown,
15
26
  } from "lucide-react";
16
27
  import { useStore } from "../store";
17
- import type { CodeSnippet } from "../types";
28
+ import type { CodeContextRoot, CodeSnippet } from "../types";
18
29
  import CodeLineAnnotationPopup, {
19
30
  type CodeAnnotation,
20
31
  } from "./CodeLineAnnotationPopup";
@@ -76,15 +87,141 @@ const LARGE_FILE_LINE_THRESHOLD = 2_500;
76
87
  const LARGE_FILE_CHAR_THRESHOLD = 220_000;
77
88
  const VIRTUAL_ROW_HEIGHT = 20;
78
89
  const VIRTUAL_OVERSCAN = 24;
90
+ const CODE_CONTEXT_ROOT_SEPARATOR = "::";
79
91
 
80
92
  type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
81
93
 
94
+ interface CodeFileMeta {
95
+ rootId?: string;
96
+ rootName?: string;
97
+ relativePath?: string;
98
+ path?: string;
99
+ }
100
+
101
+ interface CodeFileBreadcrumbInfo {
102
+ rootName?: string;
103
+ parts: string[];
104
+ fileName: string;
105
+ fullPath: string;
106
+ }
107
+
108
+ function parseCodeContextFileId(fileId: string) {
109
+ const idx = fileId.indexOf(CODE_CONTEXT_ROOT_SEPARATOR);
110
+ if (idx === -1) return { rootId: null, relativePath: fileId };
111
+ return {
112
+ rootId: fileId.slice(0, idx),
113
+ relativePath: fileId.slice(idx + CODE_CONTEXT_ROOT_SEPARATOR.length),
114
+ };
115
+ }
116
+
117
+ function resolveCodeFileBreadcrumb(
118
+ filePath: string,
119
+ roots: CodeContextRoot[],
120
+ fetchedMeta: CodeFileMeta | null,
121
+ inlineLabel?: string,
122
+ ): CodeFileBreadcrumbInfo {
123
+ if (filePath.startsWith("inline:")) {
124
+ const label = inlineLabel ?? filePath.slice("inline:".length);
125
+ return {
126
+ rootName: "Inline",
127
+ parts: [label],
128
+ fileName: label,
129
+ fullPath: `Inline/${label}`,
130
+ };
131
+ }
132
+
133
+ const parsed = parseCodeContextFileId(filePath);
134
+ const root = parsed.rootId
135
+ ? roots.find((candidate) => candidate.id === parsed.rootId)
136
+ : (roots.find((candidate) =>
137
+ candidate.files.includes(parsed.relativePath),
138
+ ) ?? (roots.length === 1 ? roots[0] : undefined));
139
+ const relativePath = fetchedMeta?.relativePath ?? parsed.relativePath;
140
+ const rootName =
141
+ fetchedMeta?.rootName ?? root?.name ?? parsed.rootId ?? undefined;
142
+ const parts = relativePath.split("/").filter(Boolean);
143
+ const fileName = parts.at(-1) ?? relativePath;
144
+
145
+ return {
146
+ rootName,
147
+ parts: parts.length > 0 ? parts : [relativePath],
148
+ fileName,
149
+ fullPath: rootName ? `${rootName}/${relativePath}` : relativePath,
150
+ };
151
+ }
152
+
153
+ function FilePathBreadcrumb({ info }: { info: CodeFileBreadcrumbInfo }) {
154
+ const [expanded, setExpanded] = useState(false);
155
+ const shouldCollapse = Boolean(info.rootName) && info.parts.length > 2;
156
+ const visibleParts =
157
+ shouldCollapse && !expanded
158
+ ? [info.parts.at(-1) ?? info.fileName]
159
+ : info.parts;
160
+
161
+ useEffect(() => {
162
+ setExpanded(false);
163
+ }, [info.fullPath]);
164
+
165
+ const separator = <span className="text-slate-600 shrink-0">›</span>;
166
+ const toggle = (
167
+ <button
168
+ type="button"
169
+ onMouseDown={(e) => e.stopPropagation()}
170
+ onClick={() => setExpanded((value) => !value)}
171
+ className="px-1 rounded text-slate-500 hover:text-cyan-300 hover:bg-slate-700/70 shrink-0"
172
+ title={expanded ? "Collapse path" : "Expand full path"}
173
+ >
174
+
175
+ </button>
176
+ );
177
+
178
+ return (
179
+ <div
180
+ className="flex items-center gap-1 min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-xs font-mono text-slate-300"
181
+ title={info.fullPath}
182
+ >
183
+ {info.rootName && (
184
+ <>
185
+ <span className="shrink-0 text-cyan-300">{info.rootName}</span>
186
+ {separator}
187
+ </>
188
+ )}
189
+ {shouldCollapse && !expanded && (
190
+ <>
191
+ {toggle}
192
+ {separator}
193
+ </>
194
+ )}
195
+ {visibleParts.map((part, index) => {
196
+ const isLast = index === visibleParts.length - 1;
197
+ return (
198
+ <span key={`${part}-${index}`} className="contents">
199
+ <span
200
+ className={isLast ? "text-slate-200" : "text-slate-400 shrink-0"}
201
+ >
202
+ {part}
203
+ </span>
204
+ {!isLast && separator}
205
+ </span>
206
+ );
207
+ })}
208
+ {shouldCollapse && expanded && (
209
+ <>
210
+ {separator}
211
+ {toggle}
212
+ </>
213
+ )}
214
+ </div>
215
+ );
216
+ }
217
+
82
218
  interface CodeViewerContentProps {
83
219
  content: string;
84
220
  lang: string;
85
221
  selectedLines: Set<number>;
86
222
  chatContextLines: Set<number>;
87
223
  codeAnnotations: CodeAnnotation[];
224
+ activeSearchLine: number | null;
88
225
  onLineClick: (e: React.MouseEvent, lineNumber: number) => void;
89
226
  }
90
227
 
@@ -94,8 +231,10 @@ const CodeViewerContent = memo(function CodeViewerContent({
94
231
  selectedLines,
95
232
  chatContextLines,
96
233
  codeAnnotations,
234
+ activeSearchLine,
97
235
  onLineClick,
98
236
  }: CodeViewerContentProps) {
237
+ const syntaxScrollRef = useRef<HTMLDivElement>(null);
99
238
  const annotationLineSet = useMemo(
100
239
  () => new Set(codeAnnotations.map((annotation) => annotation.lineNumber)),
101
240
  [codeAnnotations],
@@ -105,6 +244,14 @@ const CodeViewerContent = memo(function CodeViewerContent({
105
244
  lineCount > LARGE_FILE_LINE_THRESHOLD ||
106
245
  content.length > LARGE_FILE_CHAR_THRESHOLD;
107
246
 
247
+ useEffect(() => {
248
+ if (!activeSearchLine || isLargeFile) return;
249
+ const target = syntaxScrollRef.current?.querySelector<HTMLElement>(
250
+ `[data-code-line="${activeSearchLine}"]`,
251
+ );
252
+ target?.scrollIntoView({ block: "center" });
253
+ }, [activeSearchLine, isLargeFile, content]);
254
+
108
255
  if (isLargeFile) {
109
256
  return (
110
257
  <VirtualizedPlainCodeViewer
@@ -112,13 +259,14 @@ const CodeViewerContent = memo(function CodeViewerContent({
112
259
  selectedLines={selectedLines}
113
260
  chatContextLines={chatContextLines}
114
261
  annotationLineSet={annotationLineSet}
262
+ activeSearchLine={activeSearchLine}
115
263
  onLineClick={onLineClick}
116
264
  />
117
265
  );
118
266
  }
119
267
 
120
268
  return (
121
- <div className="h-full overflow-auto">
269
+ <div ref={syntaxScrollRef} className="h-full overflow-auto">
122
270
  <SyntaxHighlighter
123
271
  language={lang}
124
272
  style={oneDark}
@@ -129,9 +277,13 @@ const CodeViewerContent = memo(function CodeViewerContent({
129
277
  const hasAnnotation = annotationLineSet.has(lineNumber);
130
278
  const isChatCtx = chatContextLines.has(lineNumber);
131
279
  const isSelected = selectedLines.has(lineNumber);
280
+ const isSearchActive = activeSearchLine === lineNumber;
132
281
  let bg: string | undefined;
133
282
  let outline: string | undefined;
134
- if (hasAnnotation) {
283
+ if (isSearchActive) {
284
+ bg = "rgba(34, 211, 238, 0.24)";
285
+ outline = "1px solid rgba(34, 211, 238, 0.7)";
286
+ } else if (hasAnnotation) {
135
287
  bg = "rgba(139, 92, 246, 0.2)";
136
288
  outline = "1px solid rgba(139, 92, 246, 0.35)";
137
289
  } else if (isChatCtx) {
@@ -142,6 +294,7 @@ const CodeViewerContent = memo(function CodeViewerContent({
142
294
  outline = "1px solid rgba(6, 182, 212, 0.3)";
143
295
  }
144
296
  return {
297
+ "data-code-line": lineNumber,
145
298
  onClick: (e: React.MouseEvent) =>
146
299
  lineNumber !== undefined && onLineClick(e, lineNumber),
147
300
  style: {
@@ -173,6 +326,7 @@ interface VirtualizedPlainCodeViewerProps {
173
326
  selectedLines: Set<number>;
174
327
  chatContextLines: Set<number>;
175
328
  annotationLineSet: Set<number>;
329
+ activeSearchLine: number | null;
176
330
  onLineClick: (e: React.MouseEvent, lineNumber: number) => void;
177
331
  }
178
332
 
@@ -181,6 +335,7 @@ function VirtualizedPlainCodeViewer({
181
335
  selectedLines,
182
336
  chatContextLines,
183
337
  annotationLineSet,
338
+ activeSearchLine,
184
339
  onLineClick,
185
340
  }: VirtualizedPlainCodeViewerProps) {
186
341
  const lines = useMemo(() => content.split(/\r?\n/), [content]);
@@ -198,6 +353,17 @@ function VirtualizedPlainCodeViewer({
198
353
  return () => observer.disconnect();
199
354
  }, []);
200
355
 
356
+ useEffect(() => {
357
+ const el = containerRef.current;
358
+ if (!el || !activeSearchLine) return;
359
+ const targetScrollTop = Math.max(
360
+ 0,
361
+ (activeSearchLine - 1) * VIRTUAL_ROW_HEIGHT - el.clientHeight / 2,
362
+ );
363
+ el.scrollTop = targetScrollTop;
364
+ setScrollTop(targetScrollTop);
365
+ }, [activeSearchLine]);
366
+
201
367
  const visibleStart = Math.max(
202
368
  0,
203
369
  Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN,
@@ -235,20 +401,25 @@ function VirtualizedPlainCodeViewer({
235
401
  const hasAnnotation = annotationLineSet.has(lineNumber);
236
402
  const isChatCtx = chatContextLines.has(lineNumber);
237
403
  const isSelected = selectedLines.has(lineNumber);
238
- const backgroundColor = hasAnnotation
239
- ? "rgba(139, 92, 246, 0.2)"
240
- : isChatCtx
241
- ? "rgba(245, 158, 11, 0.15)"
242
- : isSelected
243
- ? "rgba(6, 182, 212, 0.15)"
244
- : undefined;
245
- const outline = hasAnnotation
246
- ? "1px solid rgba(139, 92, 246, 0.35)"
247
- : isChatCtx
248
- ? "1px solid rgba(245, 158, 11, 0.3)"
249
- : isSelected
250
- ? "1px solid rgba(6, 182, 212, 0.3)"
251
- : undefined;
404
+ const isSearchActive = activeSearchLine === lineNumber;
405
+ const backgroundColor = isSearchActive
406
+ ? "rgba(34, 211, 238, 0.24)"
407
+ : hasAnnotation
408
+ ? "rgba(139, 92, 246, 0.2)"
409
+ : isChatCtx
410
+ ? "rgba(245, 158, 11, 0.15)"
411
+ : isSelected
412
+ ? "rgba(6, 182, 212, 0.15)"
413
+ : undefined;
414
+ const outline = isSearchActive
415
+ ? "1px solid rgba(34, 211, 238, 0.7)"
416
+ : hasAnnotation
417
+ ? "1px solid rgba(139, 92, 246, 0.35)"
418
+ : isChatCtx
419
+ ? "1px solid rgba(245, 158, 11, 0.3)"
420
+ : isSelected
421
+ ? "1px solid rgba(6, 182, 212, 0.3)"
422
+ : undefined;
252
423
 
253
424
  return (
254
425
  <div
@@ -283,17 +454,25 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
283
454
  livePreferenceSuffix,
284
455
  inlineCodeSnippets,
285
456
  openCodeRunner,
457
+ codeContextRoots,
286
458
  } = useStore();
287
459
  // stable ref so callbacks can read the latest question id without re-creating
288
460
  const currentQuestionIdRef = useRef<string | undefined>(undefined);
289
461
  currentQuestionIdRef.current = currentQuestion?.id;
290
462
  const [content, setContent] = useState<string | null>(null);
463
+ const [fetchedFileMeta, setFetchedFileMeta] = useState<CodeFileMeta | null>(
464
+ null,
465
+ );
291
466
  const [loading, setLoading] = useState(true);
292
467
  const [error, setError] = useState<string | null>(null);
293
468
  const [maximized, setMaximized] = useState(false);
294
469
  const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
295
470
  const [addedFeedback, setAddedFeedback] = useState(false);
296
471
  const lastClickedLineRef = useRef<number | null>(null);
472
+ const [searchOpen, setSearchOpen] = useState(false);
473
+ const [searchQuery, setSearchQuery] = useState("");
474
+ const [activeSearchIndex, setActiveSearchIndex] = useState(0);
475
+ const searchInputRef = useRef<HTMLInputElement>(null);
297
476
 
298
477
  // ── Code chat ─────────────────────────────────────────────
299
478
  const [chatPanelOpen, setChatPanelOpen] = useState(false);
@@ -335,15 +514,75 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
335
514
 
336
515
  const isInline = filePath.startsWith("inline:");
337
516
  const inlineEntry = isInline ? inlineCodeSnippets[filePath] : undefined;
338
- const fileName = isInline
339
- ? (inlineEntry?.label ?? filePath.slice("inline:".length))
340
- : (filePath.split("/").pop() ?? filePath);
517
+ const breadcrumbInfo = useMemo(
518
+ () =>
519
+ resolveCodeFileBreadcrumb(
520
+ filePath,
521
+ codeContextRoots,
522
+ fetchedFileMeta,
523
+ inlineEntry?.label,
524
+ ),
525
+ [filePath, codeContextRoots, fetchedFileMeta, inlineEntry?.label],
526
+ );
527
+ const fileName = breadcrumbInfo.fileName;
528
+ const deferredSearchQuery = useDeferredValue(searchQuery);
529
+ const searchableLines = useMemo(
530
+ () => content?.split(/\r?\n/).map((line) => line.toLowerCase()) ?? [],
531
+ [content],
532
+ );
533
+ const searchMatches = useMemo(() => {
534
+ const query = deferredSearchQuery.trim().toLowerCase();
535
+ if (!query || searchableLines.length === 0) return [];
536
+ const matches: number[] = [];
537
+ searchableLines.forEach((line, index) => {
538
+ if (line.includes(query)) matches.push(index + 1);
539
+ });
540
+ return matches;
541
+ }, [deferredSearchQuery, searchableLines]);
542
+ const clampedSearchIndex =
543
+ searchMatches.length === 0
544
+ ? 0
545
+ : Math.min(activeSearchIndex, searchMatches.length - 1);
546
+ const activeSearchLine = searchMatches[clampedSearchIndex] ?? null;
547
+
548
+ useEffect(() => {
549
+ setActiveSearchIndex(0);
550
+ }, [deferredSearchQuery, content]);
551
+
552
+ useEffect(() => {
553
+ if (!searchOpen) return;
554
+ setTimeout(() => searchInputRef.current?.focus(), 0);
555
+ }, [searchOpen]);
556
+
557
+ const moveSearch = useCallback(
558
+ (direction: 1 | -1) => {
559
+ setActiveSearchIndex((prev) => {
560
+ if (searchMatches.length === 0) return 0;
561
+ return (prev + direction + searchMatches.length) % searchMatches.length;
562
+ });
563
+ },
564
+ [searchMatches.length],
565
+ );
566
+
567
+ const toggleSearch = useCallback(() => {
568
+ setSearchOpen((open) => {
569
+ const next = !open;
570
+ if (!next) {
571
+ setSearchQuery("");
572
+ setActiveSearchIndex(0);
573
+ }
574
+ return next;
575
+ });
576
+ }, []);
341
577
 
342
578
  // Fetch file content + load persisted annotations
343
579
  useEffect(() => {
344
580
  setLoading(true);
345
581
  setError(null);
582
+ setFetchedFileMeta(null);
346
583
  setSelectedLines(new Set());
584
+ setSearchQuery("");
585
+ setActiveSearchIndex(0);
347
586
  lastClickedLineRef.current = null;
348
587
  setChatContextLines(new Set());
349
588
  setChatInput("");
@@ -385,7 +624,15 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
385
624
  .then((r) => r.json())
386
625
  .then((d) => {
387
626
  if (d.error) setError(d.error);
388
- else setContent(d.content as string);
627
+ else {
628
+ setContent(d.content as string);
629
+ setFetchedFileMeta({
630
+ path: d.path,
631
+ rootId: d.rootId,
632
+ rootName: d.rootName,
633
+ relativePath: d.relativePath,
634
+ });
635
+ }
389
636
  })
390
637
  .catch(() => setError("Failed to load file."))
391
638
  .finally(() => setLoading(false));
@@ -680,11 +927,18 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
680
927
 
681
928
  useEffect(() => {
682
929
  const handler = (e: KeyboardEvent) => {
683
- if (e.key === "Escape") onClose();
930
+ if (e.key !== "Escape") return;
931
+ if (searchOpen) {
932
+ e.preventDefault();
933
+ setSearchOpen(false);
934
+ setSearchQuery("");
935
+ return;
936
+ }
937
+ onClose();
684
938
  };
685
939
  document.addEventListener("keydown", handler);
686
940
  return () => document.removeEventListener("keydown", handler);
687
- }, [onClose]);
941
+ }, [onClose, searchOpen]);
688
942
 
689
943
  // Keep refs in sync with state for use inside [] dep callbacks
690
944
  codeAnnotationsRef.current = codeAnnotations;
@@ -727,15 +981,22 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
727
981
  style={{ cursor: maximized ? "default" : "grab" }}
728
982
  >
729
983
  <GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
730
- <span
731
- className="text-xs font-mono text-slate-300 truncate flex-1"
732
- title={filePath}
733
- >
734
- {fileName}
735
- </span>
984
+ <FilePathBreadcrumb info={breadcrumbInfo} />
736
985
  <span className="text-[10px] text-slate-600 shrink-0 uppercase tracking-wider hidden sm:block">
737
986
  {lang}
738
987
  </span>
988
+ <button
989
+ onMouseDown={(e) => e.stopPropagation()}
990
+ onClick={toggleSearch}
991
+ className={`p-1 rounded transition-colors shrink-0 ${
992
+ searchOpen
993
+ ? "text-cyan-300 bg-cyan-500/10 hover:bg-cyan-500/20"
994
+ : "text-slate-500 hover:bg-slate-700 hover:text-slate-300"
995
+ }`}
996
+ title="Search in file"
997
+ >
998
+ <Search className="w-3.5 h-3.5" />
999
+ </button>
739
1000
  <button
740
1001
  onClick={toggleMax}
741
1002
  className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
@@ -796,6 +1057,64 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
796
1057
  </button>
797
1058
  </div>
798
1059
 
1060
+ {searchOpen && (
1061
+ <div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-slate-900 border-b border-slate-800">
1062
+ <Search className="w-3.5 h-3.5 text-cyan-400/70 shrink-0" />
1063
+ <input
1064
+ ref={searchInputRef}
1065
+ value={searchQuery}
1066
+ onChange={(e) => setSearchQuery(e.target.value)}
1067
+ onKeyDown={(e) => {
1068
+ if (e.key === "Enter") {
1069
+ e.preventDefault();
1070
+ moveSearch(e.shiftKey ? -1 : 1);
1071
+ }
1072
+ if (e.key === "Escape") {
1073
+ e.preventDefault();
1074
+ e.stopPropagation();
1075
+ setSearchOpen(false);
1076
+ setSearchQuery("");
1077
+ }
1078
+ }}
1079
+ placeholder="Search in this file…"
1080
+ className="flex-1 min-w-0 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
1081
+ />
1082
+ <span className="text-[10px] text-slate-500 font-mono shrink-0 min-w-10 text-right">
1083
+ {searchQuery.trim()
1084
+ ? searchMatches.length > 0
1085
+ ? `${clampedSearchIndex + 1}/${searchMatches.length}`
1086
+ : "0/0"
1087
+ : ""}
1088
+ </span>
1089
+ <button
1090
+ onClick={() => moveSearch(-1)}
1091
+ disabled={searchMatches.length === 0}
1092
+ className="p-1 rounded text-slate-500 hover:text-cyan-300 hover:bg-slate-800 disabled:opacity-40 disabled:hover:text-slate-500 disabled:hover:bg-transparent"
1093
+ title="Previous match"
1094
+ >
1095
+ <ChevronUp className="w-3.5 h-3.5" />
1096
+ </button>
1097
+ <button
1098
+ onClick={() => moveSearch(1)}
1099
+ disabled={searchMatches.length === 0}
1100
+ className="p-1 rounded text-slate-500 hover:text-cyan-300 hover:bg-slate-800 disabled:opacity-40 disabled:hover:text-slate-500 disabled:hover:bg-transparent"
1101
+ title="Next match"
1102
+ >
1103
+ <ChevronDown className="w-3.5 h-3.5" />
1104
+ </button>
1105
+ <button
1106
+ onClick={() => {
1107
+ setSearchOpen(false);
1108
+ setSearchQuery("");
1109
+ }}
1110
+ className="p-1 rounded text-slate-500 hover:text-red-400 hover:bg-slate-800"
1111
+ title="Close search"
1112
+ >
1113
+ <X className="w-3.5 h-3.5" />
1114
+ </button>
1115
+ </div>
1116
+ )}
1117
+
799
1118
  {/* ── Content ── */}
800
1119
  <div className="flex-1 min-h-0">
801
1120
  {loading && (
@@ -815,6 +1134,7 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
815
1134
  selectedLines={selectedLines}
816
1135
  chatContextLines={chatContextLines}
817
1136
  codeAnnotations={codeAnnotations}
1137
+ activeSearchLine={activeSearchLine}
818
1138
  onLineClick={handleLineClick}
819
1139
  />
820
1140
  )}
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.25.0"
2
+ "version": "0.26.0"
3
3
  }