1import { useEffect, useRef, type KeyboardEvent } from "react";
2import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react";
3import { Button } from "../ui/button";
4import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
5import { cn } from "~/lib/utils";
6import { formatThreadFindCount } from "./threadFind";
7import { THREAD_DETAILS_CARD_GAP } from "./threadDetailsCardLayout";
8
9interface ThreadFindBarProps {
10 readonly open: boolean;
11 readonly query: string;
12 readonly matchCount: number;
13 readonly counting?: boolean;
14 readonly status: "loading" | "error" | null;
15 readonly onRetry: () => void;
16 readonly activeIndex: number;
17 readonly focusRequestId: number;
18 readonly onQueryChange: (query: string) => void;
19 readonly onNext: () => void;
20 readonly onPrevious: () => void;
21 readonly onClose: () => void;
22}
23
24/**
25 * Room the open bar takes at the top of the chat canvas: its `h-9` height plus
26 * the shared 12px gap below it, so the details card starts on the same rhythm.
27 */
28export const THREAD_FIND_BAR_RESERVED_HEIGHT = 36 + THREAD_DETAILS_CARD_GAP;
29
30export function ThreadFindBar(props: ThreadFindBarProps) {
31 const inputRef = useRef<HTMLInputElement | null>(null);
32
33 useEffect(() => {
34 if (!props.open) return;
35 inputRef.current?.focus();
36 inputRef.current?.select();
37 }, [props.focusRequestId, props.open]);
38
39 if (!props.open) return null;
40
41 const hasQuery = props.query.trim().length > 0;
42 const noResults = hasQuery && !props.status && props.matchCount === 0;
43 let label = "";
44 if (hasQuery) {
45 label = props.counting ? "1/…" : formatThreadFindCount(props.activeIndex, props.matchCount);
46 }
47 if (props.status === "loading") label = "Searching…";
48 if (props.status === "error") label = "Search failed";
49 const navigationDisabled =
50 props.matchCount === 0 || props.status === "loading" || props.counting === true;
51 const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
52 if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
53 if (event.key === "Enter") {
54 event.preventDefault();
55 event.stopPropagation();
56 if (event.shiftKey) props.onPrevious();
57 else props.onNext();
58 }
59 };
60
61 return (
62 // Fades in while the details popover slides out from under it.
63 <div
64 data-thread-find-bar
65 className="absolute top-3 right-3 z-40 w-[min(var(--thread-details-panel-width),calc(100%-1.5rem))] transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none"
66 >
67 <InputGroup
68 variant="popover"
69 size="lg"
70 onContextMenu={(event) => event.stopPropagation()}
71 role="search"
72 aria-label="Find in thread"
73 aria-busy={props.status === "loading" || props.counting === true}
74 className="w-full"
75 >
76 <InputGroupInput
77 ref={inputRef}
78 type="search"
79 size="sm"
80 value={props.query}
81 aria-label="Find in thread"
82 placeholder="Find in thread"
83 maxLength={200}
84 spellCheck={false}
85 autoComplete="off"
86 onChange={(event) => props.onQueryChange(event.target.value)}
87 onKeyDown={handleKeyDown}
88 />
89 <InputGroupAddon align="inline-end" inset="pill">
90 <span
91 aria-live="polite"
92 className={cn(
93 "min-w-10 text-center text-xs tabular-nums",
94 noResults ? "text-destructive" : "text-muted-foreground",
95 )}
96 >
97 {label}
98 </span>
99 {props.status === "error" ? (
100 <Button size="xs" variant="ghost" onClick={props.onRetry}>
101 Retry
102 </Button>
103 ) : null}
104 <Button
105 size="icon-xs"
106 variant="ghost"
107 aria-label="Previous match"
108 disabled={navigationDisabled}
109 onClick={props.onPrevious}
110 >
111 <ChevronUpIcon />
112 </Button>
113 <Button
114 size="icon-xs"
115 variant="ghost"
116 aria-label="Next match"
117 disabled={navigationDisabled}
118 onClick={props.onNext}
119 >
120 <ChevronDownIcon />
121 </Button>
122 <Button size="icon-xs" variant="ghost" aria-label="Close find" onClick={props.onClose}>
123 <XIcon />
124 </Button>
125 </InputGroupAddon>
126 </InputGroup>
127 </div>
128 );
129}