Files
Sybil-2/web/src/components/search/search-results-panel.tsx

254 lines
9.4 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState } from "preact/hooks";
import type { SearchDetail } from "@/lib/api";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { cn } from "@/lib/utils";
2026-02-14 00:22:19 -08:00
function formatHost(url: string) {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
function normalizeHref(href: string) {
try {
const parsed = new URL(href);
parsed.hash = "";
const normalized = parsed.toString();
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
} catch {
return href.trim().replace(/\/$/, "");
}
}
2026-02-14 00:22:19 -08:00
type Props = {
search: SearchDetail | null;
isLoading: boolean;
isRunning: boolean;
className?: string;
2026-02-14 21:37:58 -08:00
enableKeyboardNavigation?: boolean;
2026-02-14 21:56:50 -08:00
openLinksInNewTab?: boolean;
2026-02-14 00:22:19 -08:00
};
2026-02-14 21:56:50 -08:00
export function SearchResultsPanel({
search,
isLoading,
isRunning,
className,
enableKeyboardNavigation = false,
openLinksInNewTab = true,
}: Props) {
const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]";
const [isAnswerExpanded, setIsAnswerExpanded] = useState(false);
const [canExpandAnswer, setCanExpandAnswer] = useState(false);
2026-02-14 21:37:58 -08:00
const [activeResultIndex, setActiveResultIndex] = useState(-1);
const answerBodyRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setIsAnswerExpanded(false);
setCanExpandAnswer(false);
}, [search?.id, search?.answerText]);
useEffect(() => {
const el = answerBodyRef.current;
if (!el || !search?.answerText) {
setCanExpandAnswer(false);
return;
}
if (isAnswerExpanded) {
setCanExpandAnswer(true);
return;
}
setCanExpandAnswer(el.scrollHeight - el.clientHeight > 1);
}, [search?.answerText, isAnswerExpanded]);
2026-02-14 21:37:58 -08:00
useEffect(() => {
setActiveResultIndex(search?.results.length ? 0 : -1);
}, [search?.id, search?.results.length]);
useEffect(() => {
if (!enableKeyboardNavigation) return;
const onKeyDown = (event: KeyboardEvent) => {
if (!search?.results.length || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
setActiveResultIndex((current) => Math.max(0, (current < 0 ? 0 : current) - 1));
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
setActiveResultIndex((current) => Math.min(search.results.length - 1, current + 1));
return;
}
if (event.key !== "Enter") return;
const result = search.results[activeResultIndex >= 0 ? activeResultIndex : 0];
if (!result?.url) return;
event.preventDefault();
2026-02-14 21:56:50 -08:00
if (openLinksInNewTab) {
window.open(result.url, "_blank", "noopener,noreferrer");
} else {
window.location.assign(result.url);
}
2026-02-14 21:37:58 -08:00
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
2026-02-14 21:56:50 -08:00
}, [activeResultIndex, enableKeyboardNavigation, openLinksInNewTab, search]);
2026-02-14 21:37:58 -08:00
const citationEntries = (search?.answerCitations ?? [])
.map((citation, index) => {
const href = citation.url || citation.id || "";
if (!href) return null;
return {
href,
normalizedHref: normalizeHref(href),
index: index + 1,
label: citation.title?.trim() || formatHost(href),
};
})
.filter((entry): entry is { href: string; normalizedHref: string; index: number; label: string } => !!entry);
const resolveCitationIndex = (href: string) => {
const normalized = normalizeHref(href);
return citationEntries.find((entry) => entry.normalizedHref === normalized)?.index;
};
const hasAnswerText = !!search?.answerText;
const isAnswerLoading = isRunning && !hasAnswerText;
const hasCitations = citationEntries.length > 0;
const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations);
2026-02-14 00:22:19 -08:00
return (
<div className={className ?? "mx-auto w-full max-w-4xl"}>
{search?.query ? (
<div className="mb-5">
<p className="text-sm text-muted-foreground">Results for</p>
<h2 className="mt-1 break-words text-xl font-semibold">{search.query}</h2>
2026-02-14 00:22:19 -08:00
<p className="mt-1 text-xs text-muted-foreground">
{search.results.length} result{search.results.length === 1 ? "" : "s"}
{search.latencyMs ? `${search.latencyMs} ms` : ""}
</p>
</div>
) : null}
{(isRunning || !!search?.answerText || !!search?.answerError) && (
2026-02-14 20:34:10 -08:00
<section className="mb-6 rounded-xl border border-violet-400/35 bg-[hsl(276_31%_15%)] p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-violet-300/90">Answer</p>
{(isAnswerLoading || hasAnswerText) ? (
<div className="mt-2">
<div className="relative">
<div
ref={answerBodyRef}
className={cn(
"overflow-hidden",
!isAnswerExpanded && ANSWER_COLLAPSED_HEIGHT_CLASS
)}
>
{isAnswerLoading ? (
<div className="text-sm text-muted-foreground">Generating answer...</div>
) : (
<MarkdownContent
markdown={search?.answerText ?? ""}
mode="citationTokens"
resolveCitationIndex={resolveCitationIndex}
2026-02-14 20:34:10 -08:00
className="text-sm leading-6 text-violet-50"
/>
)}
</div>
{!isAnswerExpanded && (isExpandable || isAnswerLoading) ? (
2026-02-14 20:34:10 -08:00
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-7 bg-gradient-to-t from-[hsl(276_31%_15%)] to-transparent" />
) : null}
</div>
<div className="mt-2 h-5">
{isExpandable ? (
<button
type="button"
2026-02-14 20:34:10 -08:00
className="inline-flex items-center gap-1 text-xs font-medium text-violet-200 hover:text-violet-100"
onClick={() => setIsAnswerExpanded((current) => !current)}
>
<span className={cn("inline-block text-[11px] transition-transform", isAnswerExpanded && "rotate-180")}></span>
{isAnswerExpanded ? "Show less" : "Show more"}
</button>
) : null}
</div>
</div>
) : null}
2026-02-14 00:22:19 -08:00
{search?.answerError ? <p className="mt-2 text-sm text-red-500">{search.answerError}</p> : null}
{isAnswerExpanded && !!citationEntries.length && (
2026-02-14 00:22:19 -08:00
<div className="mt-3 flex flex-wrap gap-2">
{citationEntries.slice(0, 8).map((citation) => {
2026-02-14 00:22:19 -08:00
return (
<a
key={`${citation.href}-${citation.index}`}
href={citation.href}
2026-02-14 21:56:50 -08:00
target={openLinksInNewTab ? "_blank" : undefined}
rel={openLinksInNewTab ? "noreferrer" : undefined}
className="max-w-full truncate rounded-md border border-violet-400/40 px-2 py-1 text-xs text-violet-200 hover:bg-violet-500/20"
2026-02-14 00:22:19 -08:00
>
2026-02-14 20:34:10 -08:00
<span className="mr-1 rounded bg-violet-900/70 px-1 py-0.5 text-[10px] text-violet-100">{citation.index}</span>
{citation.label}
2026-02-14 00:22:19 -08:00
</a>
);
})}
</div>
)}
</section>
)}
{(isLoading || isRunning) && !search?.results.length ? (
<p className="text-sm text-muted-foreground">{isRunning ? "Searching Exa..." : "Loading search..."}</p>
) : null}
{!isLoading && !isRunning && !!search?.query && search.results.length === 0 ? (
<p className="text-sm text-muted-foreground">No results found.</p>
) : null}
<div className="space-y-6">
2026-02-14 21:37:58 -08:00
{search?.results.map((result, index) => {
2026-02-14 00:22:19 -08:00
return (
2026-02-14 21:37:58 -08:00
<article
key={result.id}
className={cn(
"rounded-lg border border-border bg-[hsl(276_30%_13%)] px-4 py-4 shadow-sm transition-colors",
index === activeResultIndex && "border-violet-300 ring-1 ring-violet-300/80"
)}
>
<p className="truncate text-xs text-violet-300/85">{formatHost(result.url)}</p>
2026-02-14 21:56:50 -08:00
<a
href={result.url}
target={openLinksInNewTab ? "_blank" : undefined}
rel={openLinksInNewTab ? "noreferrer" : undefined}
className="mt-1 block break-words text-lg font-medium text-violet-300 hover:underline"
2026-02-14 21:56:50 -08:00
>
2026-02-14 00:22:19 -08:00
{result.title || result.url}
</a>
{(result.publishedDate || result.author) && (
<p className="mt-1 text-xs text-muted-foreground">{[result.publishedDate, result.author].filter(Boolean).join(" • ")}</p>
)}
{result.url ? <p className="mt-2 break-all text-sm leading-6 text-violet-100/90">{result.url}</p> : null}
2026-02-14 00:22:19 -08:00
</article>
);
})}
</div>
{search?.error ? <p className="mt-4 text-sm text-red-600">{search.error}</p> : null}
</div>
);
}