Files
Sybil-2/web/src/pages/search-route-page.tsx

269 lines
8.0 KiB
TypeScript
Raw Normal View History

2026-02-14 00:22:19 -08:00
import { useEffect, useRef, useState } from "preact/hooks";
import { Search } from "lucide-preact";
import { AuthScreen } from "@/components/auth/auth-screen";
import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
2026-02-14 01:53:34 -08:00
import { createSearch, runSearchStream, type SearchDetail } from "@/lib/api";
2026-02-14 00:22:19 -08:00
import { useSessionAuth } from "@/hooks/use-session-auth";
function readQueryFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get("q")?.trim() ?? "";
}
function pushSearchQuery(query: string) {
const params = new URLSearchParams(window.location.search);
if (query.trim()) {
params.set("q", query.trim());
} else {
params.delete("q");
}
const next = `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ""}`;
window.history.pushState({}, "", next);
}
function isInterruptedStreamError(message: string | null | undefined) {
if (!message) return false;
const normalized = message.toLowerCase();
return (
normalized.includes("abort") ||
normalized.includes("interrupted") ||
normalized.includes("cancelled") ||
2026-02-15 13:37:06 -08:00
normalized.includes("canceled") ||
normalized.includes("load failed") ||
normalized.includes("failed to fetch")
);
}
2026-02-14 00:22:19 -08:00
export default function SearchRoutePage() {
const {
authTokenInput,
setAuthTokenInput,
isCheckingSession,
isSigningIn,
isAuthenticated,
authError,
handleAuthFailure,
handleSignIn,
} = useSessionAuth();
const [queryInput, setQueryInput] = useState(readQueryFromUrl());
const [routeQuery, setRouteQuery] = useState(readQueryFromUrl());
const [search, setSearch] = useState<SearchDetail | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestCounterRef = useRef(0);
2026-02-14 01:53:34 -08:00
const streamAbortRef = useRef<AbortController | null>(null);
2026-02-14 00:22:19 -08:00
useEffect(() => {
const onPopState = () => {
const next = readQueryFromUrl();
setRouteQuery(next);
setQueryInput(next);
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
2026-02-14 21:31:09 -08:00
useEffect(() => {
document.title = routeQuery ? `${routeQuery} — Sybil` : "Sybil";
}, [routeQuery]);
useEffect(() => {
return () => {
document.title = "Sybil";
};
}, []);
2026-02-14 01:53:34 -08:00
useEffect(() => {
return () => {
streamAbortRef.current?.abort();
streamAbortRef.current = null;
};
}, []);
2026-02-14 00:22:19 -08:00
const runQuery = async (query: string) => {
const trimmed = query.trim();
if (!trimmed) {
setSearch(null);
setError(null);
return;
}
const requestId = ++requestCounterRef.current;
2026-02-14 01:53:34 -08:00
streamAbortRef.current?.abort();
const abortController = new AbortController();
streamAbortRef.current = abortController;
2026-02-14 00:22:19 -08:00
setError(null);
setIsRunning(true);
const nowIso = new Date().toISOString();
setSearch({
id: `temp-search-${requestId}`,
title: trimmed.slice(0, 80),
query: trimmed,
createdAt: nowIso,
updatedAt: nowIso,
requestId: null,
latencyMs: null,
error: null,
answerText: null,
answerRequestId: null,
answerCitations: null,
answerError: null,
results: [],
});
try {
const created = await createSearch({
query: trimmed,
title: trimmed.slice(0, 80),
});
2026-02-14 01:53:34 -08:00
if (requestId !== requestCounterRef.current) return;
setSearch((current) =>
current
? {
...current,
id: created.id,
title: created.title,
query: created.query,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
}
: current
);
await runSearchStream(
created.id,
{
query: trimmed,
title: trimmed.slice(0, 80),
type: "auto",
numResults: 10,
},
{
onSearchResults: (payload) => {
if (requestId !== requestCounterRef.current) return;
setSearch((current) =>
current
? {
...current,
requestId: payload.requestId ?? current.requestId,
error: null,
results: payload.results,
}
: current
);
},
onSearchError: (payload) => {
if (requestId !== requestCounterRef.current) return;
setSearch((current) => (current ? { ...current, error: payload.error } : current));
},
onAnswer: (payload) => {
if (requestId !== requestCounterRef.current) return;
setSearch((current) =>
current
? {
...current,
answerText: payload.answerText,
answerRequestId: payload.answerRequestId,
answerCitations: payload.answerCitations,
answerError: null,
}
: current
);
},
onAnswerError: (payload) => {
if (requestId !== requestCounterRef.current) return;
if (isInterruptedStreamError(payload.error)) return;
2026-02-14 01:53:34 -08:00
setSearch((current) => (current ? { ...current, answerError: payload.error } : current));
},
onDone: (payload) => {
if (requestId !== requestCounterRef.current) return;
setSearch(payload.search);
},
onError: (payload) => {
if (requestId !== requestCounterRef.current) return;
if (isInterruptedStreamError(payload.message)) return;
2026-02-14 01:53:34 -08:00
setError(payload.message);
},
},
{ signal: abortController.signal }
);
2026-02-14 00:22:19 -08:00
} catch (err) {
2026-02-14 01:53:34 -08:00
if (abortController.signal.aborted) return;
2026-02-14 00:22:19 -08:00
const message = err instanceof Error ? err.message : String(err);
if (isInterruptedStreamError(message)) return;
2026-02-14 00:22:19 -08:00
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else if (requestId === requestCounterRef.current) {
setError(message);
}
} finally {
if (requestId === requestCounterRef.current) {
2026-02-14 01:53:34 -08:00
streamAbortRef.current = null;
2026-02-14 00:22:19 -08:00
setIsRunning(false);
}
}
};
useEffect(() => {
if (!isAuthenticated) return;
void runQuery(routeQuery);
}, [isAuthenticated, routeQuery]);
if (isCheckingSession) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Checking session...</p>
</div>
);
}
if (!isAuthenticated) {
return (
<AuthScreen
authTokenInput={authTokenInput}
setAuthTokenInput={setAuthTokenInput}
isSigningIn={isSigningIn}
authError={authError}
onSignIn={handleSignIn}
/>
);
}
return (
<div className="h-full overflow-y-auto px-3 py-6 md:px-6">
<div className="mx-auto w-full max-w-4xl space-y-5">
<form
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"
onSubmit={(event) => {
event.preventDefault();
const next = queryInput.trim();
pushSearchQuery(next);
setRouteQuery(next);
}}
>
<Input value={queryInput} onInput={(event) => setQueryInput(event.currentTarget.value)} placeholder="Search the web" />
<Button type="submit" size="icon" disabled={!queryInput.trim() || isRunning}>
<Search className="h-4 w-4" />
</Button>
</form>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
2026-02-14 21:56:50 -08:00
<SearchResultsPanel
search={search}
isLoading={false}
isRunning={isRunning}
className="w-full"
enableKeyboardNavigation
openLinksInNewTab={false}
/>
2026-02-14 00:22:19 -08:00
</div>
</div>
);
}