import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal } from "lucide-preact"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; 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, getChat, getSearch, listChats, listSearches, runCompletion, runSearch, type ChatDetail, type ChatSummary, type CompletionRequestMessage, type SearchDetail, type SearchSummary, } from "@/lib/api"; import { useSessionAuth } from "@/hooks/use-session-auth"; import { cn } from "@/lib/utils"; type Provider = "openai" | "anthropic" | "xai"; type SidebarSelection = { kind: "chat" | "search"; id: string }; type DraftSelectionKind = "chat" | "search"; type SidebarItem = SidebarSelection & { title: string; updatedAt: string; createdAt: string; }; const PROVIDER_DEFAULT_MODELS: Record = { openai: "gpt-4.1-mini", anthropic: "claude-3-5-sonnet-latest", xai: "grok-3-mini", }; 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, })), ...searches.map((search) => ({ kind: "search" as const, id: search.id, title: getSearchTitle(search), updatedAt: search.updatedAt, createdAt: search.createdAt, })), ]; 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 [composer, setComposer] = useState(""); const [provider, setProvider] = useState("openai"); const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai); const [error, setError] = useState(null); const transcriptEndRef = useRef(null); const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); const resetWorkspaceState = () => { setChats([]); setSearches([]); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); setDraftKind(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 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; void refreshCollections(); }, [isAuthenticated]); const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; 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; transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, [draftKind, selectedChat?.messages.length, isSending, selectedItem?.kind]); const messages = selectedChat?.messages ?? []; 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]); 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 isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search"; const isSearchRunning = isSending && isSearchMode; const handleCreateChat = () => { setError(null); setDraftKind("chat"); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); }; const handleCreateSearch = () => { setError(null); setDraftKind("search"); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); }; const handleSendChat = async (content: string) => { let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null; if (!chatId) { const chat = await createChat(); chatId = chat.id; setDraftKind(null); setSelectedItem({ kind: "chat", id: chatId }); setSelectedChat({ id: chat.id, title: chat.title, createdAt: chat.createdAt, updatedAt: chat.updatedAt, 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 optimisticUserMessage = { id: `temp-user-${Date.now()}`, createdAt: new Date().toISOString(), role: "user" as const, content, name: null, }; const optimisticAssistantMessage = { id: `temp-assistant-${Date.now()}`, createdAt: new Date().toISOString(), role: "assistant" as const, content: "", name: null, }; setSelectedChat((current) => { if (!current || current.id !== chatId) return current; return { ...current, messages: [...current.messages, optimisticUserMessage, optimisticAssistantMessage], }; }); const requestMessages: CompletionRequestMessage[] = [ ...baseChat.messages.map((message) => ({ role: message.role, content: message.content, ...(message.name ? { name: message.name } : {}), })), { role: "user", content, }, ]; await runCompletion({ chatId, provider, model: model.trim(), messages: requestMessages, }); await Promise.all([refreshCollections({ kind: "chat", id: chatId }), refreshChat(chatId)]); }; const handleSendSearch = async (query: string) => { 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: [], }; }); const search = await runSearch(searchId, { query, title: query.slice(0, 80), type: "auto", numResults: 10, }); setSelectedSearch(search); setSelectedChat(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 (selectedItem?.kind === "chat") { await refreshChat(selectedItem.id); } if (selectedItem?.kind === "search") { await refreshSearch(selectedItem.id); } } finally { setIsSending(false); } }; const handleLogout = () => { logout(); resetWorkspaceState(); }; if (isCheckingSession) { return (

Checking session...

); } if (!isAuthenticated) { return ( ); } return (

{selectedTitle}

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

{!isSearchMode ? ( <> setModel(event.currentTarget.value)} placeholder="Model" disabled={isSending} /> ) : (
Search mode
)}
{!isSearchMode ? ( ) : ( )}