214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
|||
|
|
import { Search } from "lucide-react";
|
||
|
|
import { useTranslation } from "react-i18next";
|
||
|
|
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogTitle,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import { deriveTitle } from "@/lib/format";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import type { ChatSummary } from "@/lib/types";
|
||
|
|
|
||
|
|
interface SessionSearchDialogProps {
|
||
|
|
open: boolean;
|
||
|
|
sessions: ChatSummary[];
|
||
|
|
activeKey: string | null;
|
||
|
|
loading: boolean;
|
||
|
|
titleOverrides?: Record<string, string>;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
onSelect: (key: string) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SessionSearchDialog({
|
||
|
|
open,
|
||
|
|
sessions,
|
||
|
|
activeKey,
|
||
|
|
loading,
|
||
|
|
titleOverrides = {},
|
||
|
|
onOpenChange,
|
||
|
|
onSelect,
|
||
|
|
}: SessionSearchDialogProps) {
|
||
|
|
const { t } = useTranslation();
|
||
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
|
const [query, setQuery] = useState("");
|
||
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||
|
|
|
||
|
|
const normalizedQuery = query.trim().toLowerCase();
|
||
|
|
const results = useMemo(() => {
|
||
|
|
if (!normalizedQuery) return sessions;
|
||
|
|
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
||
|
|
return sessions.filter((session) =>
|
||
|
|
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
|
||
|
|
);
|
||
|
|
}, [normalizedQuery, sessions, titleOverrides]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return;
|
||
|
|
setQuery("");
|
||
|
|
setHighlightedIndex(0);
|
||
|
|
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setHighlightedIndex(0);
|
||
|
|
}, [normalizedQuery]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setHighlightedIndex((index) =>
|
||
|
|
results.length === 0 ? 0 : Math.min(index, results.length - 1),
|
||
|
|
);
|
||
|
|
}, [results.length]);
|
||
|
|
|
||
|
|
const handleSelect = (key: string) => {
|
||
|
|
onOpenChange(false);
|
||
|
|
onSelect(key);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||
|
|
if (event.key === "ArrowDown") {
|
||
|
|
event.preventDefault();
|
||
|
|
setHighlightedIndex((index) =>
|
||
|
|
results.length === 0 ? 0 : Math.min(index + 1, results.length - 1),
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (event.key === "ArrowUp") {
|
||
|
|
event.preventDefault();
|
||
|
|
setHighlightedIndex((index) => Math.max(index - 1, 0));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (event.key === "Enter") {
|
||
|
|
const highlighted = results[highlightedIndex];
|
||
|
|
if (!highlighted) return;
|
||
|
|
event.preventDefault();
|
||
|
|
handleSelect(highlighted.key);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const emptyLabel = normalizedQuery
|
||
|
|
? t("sidebar.noSearchResults")
|
||
|
|
: t("chat.noSessions");
|
||
|
|
const sectionLabel = normalizedQuery
|
||
|
|
? t("sidebar.searchResults")
|
||
|
|
: t("sidebar.recent");
|
||
|
|
|
||
|
|
if (!open) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogContent
|
||
|
|
showCloseButton={false}
|
||
|
|
className={cn(
|
||
|
|
"max-h-[min(34rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] gap-0 overflow-hidden p-0",
|
||
|
|
"rounded-2xl border border-border/70 bg-popover/95 text-popover-foreground shadow-2xl backdrop-blur-xl",
|
||
|
|
"sm:rounded-2xl",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
|
||
|
|
<DialogDescription className="sr-only">
|
||
|
|
{t("sidebar.searchPlaceholder")}
|
||
|
|
</DialogDescription>
|
||
|
|
<div className="flex h-14 items-center gap-3 border-b border-border/60 px-5">
|
||
|
|
<Search
|
||
|
|
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||
|
|
aria-hidden
|
||
|
|
/>
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
value={query}
|
||
|
|
onChange={(event) => setQuery(event.target.value)}
|
||
|
|
onKeyDown={handleKeyDown}
|
||
|
|
placeholder={t("sidebar.searchPlaceholder")}
|
||
|
|
aria-label={t("sidebar.searchAria")}
|
||
|
|
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
|
||
|
|
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
|
||
|
|
{sectionLabel}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{loading && sessions.length === 0 ? (
|
||
|
|
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
||
|
|
{t("chat.loading")}
|
||
|
|
</div>
|
||
|
|
) : results.length === 0 ? (
|
||
|
|
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
||
|
|
{emptyLabel}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<ul className="space-y-1">
|
||
|
|
{results.map((session, index) => {
|
||
|
|
const title = titleOverrides[session.key]?.trim() ||
|
||
|
|
session.title?.trim() ||
|
||
|
|
deriveTitle(session.preview, t("chat.newChat"));
|
||
|
|
const preview = session.preview.trim();
|
||
|
|
const showPreview =
|
||
|
|
preview.length > 0 &&
|
||
|
|
preview.toLowerCase() !== title.trim().toLowerCase();
|
||
|
|
const highlighted = index === highlightedIndex;
|
||
|
|
const active = session.key === activeKey;
|
||
|
|
return (
|
||
|
|
<li key={session.key}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => handleSelect(session.key)}
|
||
|
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||
|
|
aria-current={active ? "page" : undefined}
|
||
|
|
className={cn(
|
||
|
|
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
|
||
|
|
highlighted
|
||
|
|
? "bg-accent text-accent-foreground"
|
||
|
|
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<span className="min-w-0 flex-1">
|
||
|
|
<span className="block truncate text-[14px] font-medium leading-5">
|
||
|
|
{title}
|
||
|
|
</span>
|
||
|
|
{showPreview ? (
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
"block truncate text-[12px] leading-4",
|
||
|
|
highlighted
|
||
|
|
? "text-accent-foreground/70"
|
||
|
|
: "text-muted-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{preview}
|
||
|
|
</span>
|
||
|
|
) : null}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function sessionMatchesTerms(
|
||
|
|
session: ChatSummary,
|
||
|
|
terms: string[],
|
||
|
|
titleOverride?: string,
|
||
|
|
) {
|
||
|
|
const haystack = [
|
||
|
|
titleOverride,
|
||
|
|
session.title,
|
||
|
|
session.preview,
|
||
|
|
]
|
||
|
|
.filter(Boolean)
|
||
|
|
.join(" ")
|
||
|
|
.toLowerCase();
|
||
|
|
|
||
|
|
return terms.every((term) => haystack.includes(term));
|
||
|
|
}
|