130 lines
5.4 KiB
TypeScript
130 lines
5.4 KiB
TypeScript
// src/components/ChatSearchResults.tsx
|
||
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||
// All Rights Reserved
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { Search, X, Loader2 } from "lucide-react";
|
||
import { searchApi, ISearchResult } from "../lib/api";
|
||
|
||
interface ChatSearchResultsProps {
|
||
query: string;
|
||
filters?: { projectId?: string; sessionId?: string; turnId?: string };
|
||
onSelect: (result: ISearchResult) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export default function ChatSearchResults({
|
||
query,
|
||
filters,
|
||
onSelect,
|
||
onClose,
|
||
}: ChatSearchResultsProps) {
|
||
const [editQuery, setEditQuery] = useState(query);
|
||
const [results, setResults] = useState<ISearchResult[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const performSearch = useCallback(async () => {
|
||
if (!editQuery.trim()) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const data = await searchApi.search(editQuery, filters, 20);
|
||
setResults(data);
|
||
} catch (err) {
|
||
setError((err as Error).message || "Search failed");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [editQuery, filters]);
|
||
|
||
useEffect(() => { performSearch(); }, [performSearch]);
|
||
|
||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
performSearch();
|
||
}
|
||
};
|
||
|
||
const formatScore = (score: number): string => `${Math.round(score * 100)}%`;
|
||
|
||
const formatTimestamp = (iso: string): string => {
|
||
if (!iso) return "";
|
||
try { return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }); }
|
||
catch { return iso; }
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
|
||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||
>
|
||
<div className="bg-bg-secondary border border-border-default rounded-lg w-full max-w-3xl max-h-[80vh] flex flex-col mx-4">
|
||
{/* Header with editable search input */}
|
||
<div className="flex items-center gap-3 px-6 py-4 border-b border-border-default">
|
||
<Search className="text-text-tertiary shrink-0" size={16} />
|
||
<input
|
||
type="text"
|
||
value={editQuery}
|
||
onChange={(e) => setEditQuery(e.target.value)}
|
||
onKeyDown={handleSearchKeyDown}
|
||
className="flex-1 bg-transparent text-text-primary text-sm focus:outline-none placeholder:text-text-tertiary"
|
||
placeholder="Edit search query…"
|
||
autoFocus
|
||
/>
|
||
<button
|
||
onClick={performSearch}
|
||
className="text-text-tertiary hover:text-brand transition-colors shrink-0"
|
||
aria-label="Submit search"
|
||
>
|
||
<Search size={16} />
|
||
</button>
|
||
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary transition-colors p-1 shrink-0" aria-label="Close">
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Result count bar */}
|
||
{!loading && (
|
||
<div className="px-6 py-2 text-sm text-text-tertiary border-b border-border-default">
|
||
{results.length} result{results.length !== 1 ? "s" : ""} for "{query === editQuery ? query : editQuery}"
|
||
</div>
|
||
)}
|
||
|
||
<div className="overflow-y-auto flex-1 p-4">
|
||
{loading && (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="text-brand text-2xl animate-spin" />
|
||
<span className="ml-3 text-text-secondary">Searching…</span>
|
||
</div>
|
||
)}
|
||
{error && <div className="text-center py-12"><p className="text-red-400">{error}</p></div>}
|
||
{!loading && !error && results.length === 0 && <div className="text-center py-12"><p className="text-text-tertiary">No results found</p></div>}
|
||
{!loading && !error && results.length > 0 && (
|
||
<div className="space-y-2">
|
||
{results.map((result) => (
|
||
<button key={result.id} onClick={() => onSelect(result)}
|
||
className="w-full text-left p-4 rounded-lg bg-bg-primary hover:bg-bg-tertiary border border-border-default hover:border-brand transition-colors group"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<span className="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-mono bg-brand/20 text-brand">{formatScore(result.score)}</span>
|
||
<div className="flex-1 min-w-0"><p className="text-sm text-text-primary line-clamp-3 whitespace-pre-wrap">{result.content}</p></div>
|
||
</div>
|
||
<div className="flex items-center gap-2 mt-2 text-xs text-text-tertiary">
|
||
{result.project?.name && <span>{result.project.name}</span>}
|
||
{result.session?.name && <><span>›</span><span>{result.session.name}</span></>}
|
||
<span>›</span>
|
||
<span className="font-mono">{result.turnId.slice(0, 8)}…</span>
|
||
{result.createdAt && <><span className="mx-1">·</span><span>{formatTimestamp(result.createdAt)}</span></>}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|