import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { Check, ChevronDown, Globe2, Menu, MessageSquare, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; import { AuthScreen } from "@/components/auth/auth-screen"; import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel"; import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { createChat, createSearch, deleteChat, deleteSearch, getChat, listModels, getSearch, listChats, listSearches, runCompletionStream, runSearchStream, suggestChatTitle, type ModelCatalogResponse, type Provider, type ChatDetail, type ChatSummary, type CompletionRequestMessage, type Message, type SearchDetail, type SearchSummary, } from "@/lib/api"; import { useSessionAuth } from "@/hooks/use-session-auth"; import { cn } from "@/lib/utils"; type SidebarSelection = { kind: "chat" | "search"; id: string }; type DraftSelectionKind = "chat" | "search"; type SidebarItem = SidebarSelection & { title: string; updatedAt: string; createdAt: string; initiatedProvider: Provider | null; initiatedModel: string | null; lastUsedProvider: Provider | null; lastUsedModel: string | null; }; type ContextMenuState = { item: SidebarSelection; x: number; y: number; }; function readSidebarSelectionFromUrl(): SidebarSelection | null { if (typeof window === "undefined") return null; const params = new URLSearchParams(window.location.search); const chatId = params.get("chat")?.trim(); if (chatId) { return { kind: "chat", id: chatId }; } const searchId = params.get("search")?.trim(); if (searchId) { return { kind: "search", id: searchId }; } return null; } function buildWorkspaceUrl(selection: SidebarSelection | null) { if (typeof window === "undefined") return "/"; const params = new URLSearchParams(window.location.search); params.delete("chat"); params.delete("search"); if (selection) { params.set(selection.kind === "chat" ? "chat" : "search", selection.id); } const query = params.toString(); return `${window.location.pathname}${query ? `?${query}` : ""}`; } const PROVIDER_FALLBACK_MODELS: Record = { openai: ["gpt-4.1-mini"], anthropic: ["claude-3-5-sonnet-latest"], xai: ["grok-3-mini"], }; const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { openai: { models: [], loadedAt: null, error: null }, anthropic: { models: [], loadedAt: null, error: null }, xai: { models: [], loadedAt: null, error: null }, }; const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider"; type ProviderModelPreferences = Record; const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = { openai: null, anthropic: null, xai: null, }; function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) { const providerModels = catalog[provider]?.models ?? []; if (providerModels.length) return providerModels; return PROVIDER_FALLBACK_MODELS[provider]; } function loadStoredModelPreferences() { if (typeof window === "undefined") return EMPTY_MODEL_PREFERENCES; try { const raw = window.localStorage.getItem(MODEL_PREFERENCES_STORAGE_KEY); if (!raw) return EMPTY_MODEL_PREFERENCES; const parsed = JSON.parse(raw) as Partial>; return { openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null, anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null, xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null, }; } catch { return EMPTY_MODEL_PREFERENCES; } } function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) { if (fallback && options.includes(fallback)) return fallback; if (preferred && options.includes(preferred)) return preferred; return options[0] ?? ""; } function getProviderLabel(provider: Provider | null | undefined) { if (provider === "openai") return "OpenAI"; if (provider === "anthropic") return "Anthropic"; if (provider === "xai") return "xAI"; return ""; } function getChatModelSelection(chat: Pick | Pick | null) { if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null; return { provider: chat.lastUsedProvider, model: chat.lastUsedModel.trim(), }; } type ModelComboboxProps = { options: string[]; value: string; onChange: (value: string) => void; disabled?: boolean; }; function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const rootRef = useRef(null); const inputRef = useRef(null); const filteredOptions = useMemo(() => { const needle = query.trim().toLowerCase(); if (!needle) return options; return options.filter((option) => option.toLowerCase().includes(needle)); }, [options, query]); useEffect(() => { if (!open) return; inputRef.current?.focus(); }, [open]); useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { if (rootRef.current?.contains(event.target as Node)) return; setOpen(false); setQuery(""); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; setOpen(false); setQuery(""); }; window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleKeyDown); }; }, [open]); return (
{open ? (
setQuery(event.currentTarget.value)} className="mb-1 h-8 w-full rounded-sm border border-input bg-background px-2 text-sm outline-none" placeholder="Filter models" />
{filteredOptions.length ? ( filteredOptions.map((option) => ( )) ) : (

No models found

)}
) : null}
); } function getChatTitle(chat: Pick, messages?: ChatDetail["messages"]) { if (chat.title?.trim()) return chat.title.trim(); const firstUserMessage = messages?.find((m) => m.role === "user")?.content.trim(); if (firstUserMessage) return firstUserMessage.slice(0, 48); return "New chat"; } function getSearchTitle(search: Pick) { if (search.title?.trim()) return search.title.trim(); if (search.query?.trim()) return search.query.trim().slice(0, 64); return "New search"; } function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { const items: SidebarItem[] = [ ...chats.map((chat) => ({ kind: "chat" as const, id: chat.id, title: getChatTitle(chat), updatedAt: chat.updatedAt, createdAt: chat.createdAt, initiatedProvider: chat.initiatedProvider, initiatedModel: chat.initiatedModel, lastUsedProvider: chat.lastUsedProvider, lastUsedModel: chat.lastUsedModel, })), ...searches.map((search) => ({ kind: "search" as const, id: search.id, title: getSearchTitle(search), updatedAt: search.updatedAt, createdAt: search.createdAt, initiatedProvider: null, initiatedModel: null, lastUsedProvider: null, lastUsedModel: null, })), ]; return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } function formatDate(value: string) { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }).format(new Date(value)); } export default function App() { const { authTokenInput, setAuthTokenInput, isCheckingSession, isSigningIn, isAuthenticated, authMode, authError, handleAuthFailure: baseHandleAuthFailure, handleSignIn, logout, } = useSessionAuth(); const [chats, setChats] = useState([]); const [searches, setSearches] = useState([]); const [selectedItem, setSelectedItem] = useState(null); const [selectedChat, setSelectedChat] = useState(null); const [selectedSearch, setSelectedSearch] = useState(null); const [draftKind, setDraftKind] = useState(null); const [isLoadingCollections, setIsLoadingCollections] = useState(false); const [isLoadingSelection, setIsLoadingSelection] = useState(false); const [isSending, setIsSending] = useState(false); const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null); const [composer, setComposer] = useState(""); const [provider, setProvider] = useState("openai"); const [modelCatalog, setModelCatalog] = useState(EMPTY_MODEL_CATALOG); const [providerModelPreferences, setProviderModelPreferences] = useState(() => loadStoredModelPreferences()); const [model, setModel] = useState(() => { const stored = loadStoredModelPreferences(); return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0]; }); const [error, setError] = useState(null); const transcriptEndRef = useRef(null); const contextMenuRef = useRef(null); const selectedItemRef = useRef(null); const pendingTitleGenerationRef = useRef>(new Set()); const searchRunAbortRef = useRef(null); const searchRunCounterRef = useRef(0); const wasSendingRef = useRef(false); const [contextMenu, setContextMenu] = useState(null); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const initialRouteSelectionRef = useRef(readSidebarSelectionFromUrl()); const hasSyncedSelectionHistoryRef = useRef(false); const focusComposer = () => { if (typeof window === "undefined") return; window.requestAnimationFrame(() => { const textarea = document.getElementById("composer-input") as HTMLTextAreaElement | null; textarea?.focus(); }); }; useEffect(() => { if (typeof document === "undefined") return; const textarea = document.getElementById("composer-input") as HTMLTextAreaElement | null; if (!textarea) return; textarea.style.height = "0px"; textarea.style.height = `${textarea.scrollHeight}px`; }, [composer]); const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); const resetWorkspaceState = () => { setChats([]); setSearches([]); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); setDraftKind(null); setPendingChatState(null); setComposer(""); setError(null); }; const handleAuthFailure = (message: string) => { baseHandleAuthFailure(message); resetWorkspaceState(); }; const refreshCollections = async (preferredSelection?: SidebarSelection) => { setIsLoadingCollections(true); try { const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]); const nextItems = buildSidebarItems(nextChats, nextSearches); setChats(nextChats); setSearches(nextSearches); setSelectedItem((current) => { const hasItem = (candidate: SidebarSelection | null) => { if (!candidate) return false; return nextItems.some((item) => item.kind === candidate.kind && item.id === candidate.id); }; if (preferredSelection && hasItem(preferredSelection)) { return preferredSelection; } if (hasItem(current)) { return current; } const first = nextItems[0]; return first ? { kind: first.kind, id: first.id } : null; }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } finally { setIsLoadingCollections(false); } }; const refreshModels = async () => { try { const data = await listModels(); setModelCatalog(data.providers); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } }; const refreshChat = async (chatId: string) => { setIsLoadingSelection(true); try { const chat = await getChat(chatId); setSelectedChat(chat); setSelectedSearch(null); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } finally { setIsLoadingSelection(false); } }; const refreshSearch = async (searchId: string) => { setIsLoadingSelection(true); try { const search = await getSearch(searchId); setSelectedSearch(search); setSelectedChat(null); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } finally { setIsLoadingSelection(false); } }; useEffect(() => { if (!isAuthenticated) return; const preferredSelection = initialRouteSelectionRef.current; initialRouteSelectionRef.current = null; void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels()]); }, [isAuthenticated]); useEffect(() => { const onPopState = () => { setContextMenu(null); setDraftKind(null); setSelectedItem(readSidebarSelectionFromUrl()); setIsMobileSidebarOpen(false); }; window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); }, []); useEffect(() => { if (!isAuthenticated) { hasSyncedSelectionHistoryRef.current = false; return; } const current = `${window.location.pathname}${window.location.search}`; const next = buildWorkspaceUrl(selectedItem); if (!hasSyncedSelectionHistoryRef.current) { hasSyncedSelectionHistoryRef.current = true; if (current !== next) { window.history.replaceState({}, "", next); } return; } if (current !== next) { window.history.pushState({}, "", next); } }, [isAuthenticated, selectedItem]); const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]); useEffect(() => { setModel((current) => { return pickProviderModel(providerModelOptions, providerModelPreferences[provider], current); }); }, [provider, providerModelOptions, providerModelPreferences]); useEffect(() => { if (typeof window === "undefined") return; window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences)); }, [providerModelPreferences]); const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; useEffect(() => { selectedItemRef.current = selectedItem; }, [selectedItem]); useEffect(() => { if (!isAuthenticated) { setSelectedChat(null); setSelectedSearch(null); return; } if (!selectedItem) { setSelectedChat(null); setSelectedSearch(null); return; } if (selectedItem.kind === "chat") { void refreshChat(selectedItem.id); return; } void refreshSearch(selectedItem.id); }, [isAuthenticated, selectedKey]); useEffect(() => { if (draftKind === "search" || selectedItem?.kind === "search") return; const wasSending = wasSendingRef.current; wasSendingRef.current = isSending; if (wasSending && !isSending) return; transcriptEndRef.current?.scrollIntoView({ behavior: isSending ? "smooth" : "auto", block: "end" }); }, [draftKind, selectedChat?.messages.length, isSending, selectedItem?.kind, selectedKey]); useEffect(() => { if (isSending) return; const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null; if (!hasWorkspaceSelection) return; focusComposer(); }, [draftKind, isSending, selectedKey]); useEffect(() => { return () => { searchRunAbortRef.current?.abort(); searchRunAbortRef.current = null; }; }, []); const messages = selectedChat?.messages ?? []; const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search"; const isSearchRunning = isSending && isSearchMode; const isSendingActiveChat = isSending && !isSearchMode && !!pendingChatState && !!pendingChatState.chatId && selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId; const displayMessages = useMemo(() => { if (!pendingChatState) return messages; if (pendingChatState.chatId) { if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) { return pendingChatState.messages; } return messages; } return isSearchMode ? messages : pendingChatState.messages; }, [isSearchMode, messages, pendingChatState, selectedItem]); const selectedChatSummary = useMemo(() => { if (!selectedItem || selectedItem.kind !== "chat") return null; return chats.find((chat) => chat.id === selectedItem.id) ?? null; }, [chats, selectedItem]); const selectedSearchSummary = useMemo(() => { if (!selectedItem || selectedItem.kind !== "search") return null; return searches.find((search) => search.id === selectedItem.id) ?? null; }, [searches, selectedItem]); useEffect(() => { if (draftKind || selectedItem?.kind !== "chat") return; const detailSelection = selectedChat?.id === selectedItem.id ? getChatModelSelection(selectedChat) : null; const summarySelection = getChatModelSelection(selectedChatSummary); const nextSelection = detailSelection ?? summarySelection; if (!nextSelection) return; setProvider(nextSelection.provider); setModel(nextSelection.model); }, [draftKind, selectedChat, selectedChatSummary, selectedItem]); const selectedTitle = useMemo(() => { if (draftKind === "chat") return "New chat"; if (draftKind === "search") return "New search"; if (!selectedItem) return "Sybil"; if (selectedItem.kind === "chat") { if (selectedChat) return getChatTitle(selectedChat, selectedChat.messages); if (selectedChatSummary) return getChatTitle(selectedChatSummary); return "New chat"; } if (selectedSearch) return getSearchTitle(selectedSearch); if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary); return "New search"; }, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]); const pageTitle = useMemo(() => { if (draftKind || !selectedItem) return "Sybil"; if (selectedItem.kind === "chat") { if (selectedChat) return `${getChatTitle(selectedChat, selectedChat.messages)} — Sybil`; if (selectedChatSummary) return `${getChatTitle(selectedChatSummary)} — Sybil`; return "Sybil"; } const searchQuery = selectedSearch?.query?.trim() || selectedSearchSummary?.query?.trim(); if (searchQuery) return `${searchQuery} — Sybil`; if (selectedSearch) return `${getSearchTitle(selectedSearch)} — Sybil`; if (selectedSearchSummary) return `${getSearchTitle(selectedSearchSummary)} — Sybil`; return "Sybil"; }, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]); useEffect(() => { document.title = pageTitle; }, [pageTitle]); const handleCreateChat = () => { setError(null); setContextMenu(null); setDraftKind("chat"); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); setIsMobileSidebarOpen(false); }; const handleCreateSearch = () => { setError(null); setContextMenu(null); setDraftKind("search"); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); setIsMobileSidebarOpen(false); }; const openContextMenu = (event: MouseEvent, item: SidebarSelection) => { event.preventDefault(); const menuWidth = 160; const menuHeight = 40; const padding = 8; const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding); const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding); setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) }); }; const handleDeleteFromContextMenu = async () => { if (!contextMenu || isSending) return; const target = contextMenu.item; setContextMenu(null); setError(null); try { if (target.kind === "chat") { await deleteChat(target.id); } else { await deleteSearch(target.id); } await refreshCollections(); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } }; useEffect(() => { if (!contextMenu) return; const handlePointerDown = (event: PointerEvent) => { if (contextMenuRef.current?.contains(event.target as Node)) return; setContextMenu(null); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setContextMenu(null); }; window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleKeyDown); }; }, [contextMenu]); const handleSendChat = async (content: string) => { const optimisticUserMessage: Message = { id: `temp-user-${Date.now()}`, createdAt: new Date().toISOString(), role: "user", content, name: null, }; const optimisticAssistantMessage: Message = { id: `temp-assistant-${Date.now()}`, createdAt: new Date().toISOString(), role: "assistant", content: "", name: null, }; setPendingChatState({ chatId: selectedItem?.kind === "chat" ? selectedItem.id : null, messages: (selectedChat?.messages ?? []).concat(optimisticUserMessage, optimisticAssistantMessage), }); let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null; if (!chatId) { const chat = await createChat(); chatId = chat.id; setDraftKind(null); setChats((current) => { const withoutExisting = current.filter((existing) => existing.id !== chat.id); return [chat, ...withoutExisting]; }); setSelectedItem({ kind: "chat", id: chatId }); setPendingChatState((current) => (current ? { ...current, chatId } : current)); setSelectedChat({ id: chat.id, title: chat.title, createdAt: chat.createdAt, updatedAt: chat.updatedAt, initiatedProvider: chat.initiatedProvider, initiatedModel: chat.initiatedModel, lastUsedProvider: chat.lastUsedProvider, lastUsedModel: chat.lastUsedModel, messages: [], }); setSelectedSearch(null); } if (!chatId) { throw new Error("Unable to initialize chat"); } let baseChat = selectedChat; if (!baseChat || baseChat.id !== chatId) { baseChat = await getChat(chatId); } const requestMessages: CompletionRequestMessage[] = [ ...baseChat.messages.map((message) => ({ role: message.role, content: message.content, ...(message.name ? { name: message.name } : {}), })), { role: "user", content, }, ]; const selectedModel = model.trim(); if (!selectedModel) { throw new Error("No model available for selected provider"); } const chatSummary = chats.find((chat) => chat.id === chatId); const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim()); if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) { pendingTitleGenerationRef.current.add(chatId); void suggestChatTitle({ chatId, content }) .then((updatedChat) => { setChats((current) => current.map((chat) => { if (chat.id !== updatedChat.id) return chat; return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; }) ); setSelectedChat((current) => { if (!current || current.id !== updatedChat.id) return current; return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; }); }) .catch(() => { // ignore title suggestion errors so chat flow is not interrupted }) .finally(() => { pendingTitleGenerationRef.current.delete(chatId); }); } let streamErrorMessage: string | null = null; await runCompletionStream( { chatId, provider, model: selectedModel, messages: requestMessages, }, { onMeta: (payload) => { if (payload.chatId !== chatId) return; setPendingChatState((current) => (current ? { ...current, chatId: payload.chatId } : current)); }, onDelta: (payload) => { if (!payload.text) return; setPendingChatState((current) => { if (!current) return current; let updated = false; const nextMessages = current.messages.map((message, index, all) => { const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-"); if (!isTarget) return message; updated = true; return { ...message, content: message.content + payload.text }; }); return updated ? { ...current, messages: nextMessages } : current; }); }, onDone: (payload) => { setPendingChatState((current) => { if (!current) return current; let updated = false; const nextMessages = current.messages.map((message, index, all) => { const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-"); if (!isTarget) return message; updated = true; return { ...message, content: payload.text }; }); return updated ? { ...current, messages: nextMessages } : current; }); }, onError: (payload) => { streamErrorMessage = payload.message; }, } ); if (streamErrorMessage) { throw new Error(streamErrorMessage); } await refreshCollections(); const currentSelection = selectedItemRef.current; if (currentSelection?.kind === "chat" && currentSelection.id === chatId) { await refreshChat(chatId); } setPendingChatState(null); }; const handleSendSearch = async (query: string) => { const runId = ++searchRunCounterRef.current; searchRunAbortRef.current?.abort(); const abortController = new AbortController(); searchRunAbortRef.current = abortController; let searchId = draftKind === "search" ? null : selectedItem?.kind === "search" ? selectedItem.id : null; if (!searchId) { const search = await createSearch({ query, title: query.slice(0, 80), }); searchId = search.id; setDraftKind(null); setSelectedItem({ kind: "search", id: searchId }); } if (!searchId) { throw new Error("Unable to initialize search"); } const nowIso = new Date().toISOString(); setSelectedSearch((current) => { if (!current || current.id !== searchId) { return { id: searchId, title: query.slice(0, 80), query, createdAt: nowIso, updatedAt: nowIso, requestId: null, latencyMs: null, error: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [], }; } return { ...current, title: query.slice(0, 80), query, error: null, latencyMs: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [], }; }); try { await runSearchStream( searchId, { query, title: query.slice(0, 80), type: "auto", numResults: 10, }, { onSearchResults: (payload) => { if (runId !== searchRunCounterRef.current) return; setSelectedSearch((current) => { if (!current || current.id !== searchId) return current; return { ...current, requestId: payload.requestId ?? current.requestId, error: null, results: payload.results, }; }); }, onSearchError: (payload) => { if (runId !== searchRunCounterRef.current) return; setSelectedSearch((current) => { if (!current || current.id !== searchId) return current; return { ...current, error: payload.error }; }); }, onAnswer: (payload) => { if (runId !== searchRunCounterRef.current) return; setSelectedSearch((current) => { if (!current || current.id !== searchId) return current; return { ...current, answerText: payload.answerText, answerRequestId: payload.answerRequestId, answerCitations: payload.answerCitations, answerError: null, }; }); }, onAnswerError: (payload) => { if (runId !== searchRunCounterRef.current) return; setSelectedSearch((current) => { if (!current || current.id !== searchId) return current; return { ...current, answerError: payload.error }; }); }, onDone: (payload) => { if (runId !== searchRunCounterRef.current) return; setSelectedSearch(payload.search); setSelectedChat(null); }, onError: (payload) => { if (runId !== searchRunCounterRef.current) return; setError(payload.message); }, }, { signal: abortController.signal } ); } catch (err) { if (abortController.signal.aborted) return; throw err; } finally { if (runId === searchRunCounterRef.current) { searchRunAbortRef.current = null; } } await refreshCollections({ kind: "search", id: searchId }); }; const handleSend = async () => { const content = composer.trim(); if (!content || isSending) return; setComposer(""); setError(null); setIsSending(true); try { if (isSearchMode) { await handleSendSearch(content); } else { await handleSendChat(content); } } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } if (!isSearchMode) { setPendingChatState(null); } if (selectedItem?.kind === "chat") { await refreshChat(selectedItem.id); } if (selectedItem?.kind === "search") { await refreshSearch(selectedItem.id); } } finally { setIsSending(false); focusComposer(); } }; if (isCheckingSession) { return (

Checking session...

); } if (!isAuthenticated) { return ( ); } return (
{isMobileSidebarOpen ? (
{isLoadingCollections && sidebarItems.length === 0 ?

Loading conversations...

: null} {!isLoadingCollections && sidebarItems.length === 0 ? (
Start a chat or run your first search.
) : null} {sidebarItems.map((item) => { const active = selectedItem?.kind === item.kind && selectedItem.id === item.id; const initiatedLabel = item.kind === "chat" && item.initiatedModel ? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}` : null; return ( ); })}

{selectedTitle}

Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""} {isSearchMode ? " • Exa Search" : ""}

{!isSearchMode ? ( <> { setModel(nextModel); setProviderModelPreferences((current) => ({ ...current, [provider]: nextModel, })); }} /> ) : (
Search mode
)}
{!isSearchMode ? ( ) : ( )}